A more react-like way to do guided tours

Recently, one of the goals at Boords was finding a nice way of introducing some of the core features of our app to new users, so we decided to go with a guided-tours approach next to the usual help articles and tutorial videos.

The basic idea is to highlight a UI element and show a popover (i.e the little overlay with text) next to it with some introductory text. Simple.

hintable preview
The new guided tour experience on Boords.com

There are some well-used guided tours libraries for React, like React Joyride and Reactour, but in the end these had the wrong feature set for what we wanted to do and did not feel very native to the way React apps work.

Here’s an example from Reacttour:

const steps = [
  {
    selector: '[data-tour="my-first-step"]',
    content: () => (
      <div>
        Lorem ipsum
      </div>
    ),
    position: 'top',
    position: [160, 250],
    style: {
      backgroundColor: '#bada55',
    },
  },
  // ...
]

Both Reacttour and React Joyride use selectors to target components in the app (.upload_button). As someone who works on React apps on a daily basis, this feels rather archaic. I feel strongly that this is the wrong approach 1 and feels prone to bugs in complex apps 2.

Being the small team that we are at Boords, I’m usually hesitant to custom-build stuff like this, but I was curious to see if I could add guided tours to our app without using selectors. In the end, I came up with a solution that:

  • Feels native to React
  • Allows waiting for specific actions
  • Works across different screens
  • Allows for callbacks in the context where the popover will be shown
  • Is typesafe where possible

So how do we attach the Tour’s popover elements to the components they need to highlight? By wrapping the target elements in a React component that listens to the tour’s current step, and conditionally renders the popover and a highlight (as seen in the screenshot above).

The hintable component

<TourHintable step="openEditor">
  <FrameImage />
</TourHintable>

This is what our component looks like in its most basic form. When the step openTheEditor is not active, it will only render its children (i.e. the component we’re wrapping), otherwise it will create a wrapper div which also contains a blue overlay and the popover component (in this case, this is Boords’ typical popover element powered by Popper).

Linking the component to the step rather than the other way around also gives another neat benefit; multiple TourHintable elements can respond to the same step. This is great when you want to highlight related elements (a canShowText property can hide the flyover for all except one instance in this case).

It was important to me that the text itself was not defined as props to TourHintable, because it would make reviewing the copy a nightmare.

This is a really neat separation of concerns. All the visual options (such as the popover position) are defined in the actual context in which the component is displayed, rather than in the place that defines the individual steps of the tour. This is something other solutions don’t do.

A tour

This is what a tour’s definition looks like in our app:

export const editingTour = {
  name: 'editing',
  onComplete: () => {
    // Pop the champagne, fire confetti, or do nothing
  };
  steps: [
    {
      name: 'openFrame',
      waitsOn: tourEvents.openFrameEditor,
      header: `🎉 This is your first frame!`,
      body: 'Double-click here to open the editor.',
    }
    {
      name: 'openImageLibrary',
      waitsOn: tourEvents.importFromImageLibrary,
      header: '👋 Welcome to the editor!',
      body: `Click on an illustration to add it to this frame.`,
    },
    // ...
  ]
}

As you can see, the text for each step in the tour is defined here, this makes it easy to review and make tweaks to copy. Each step has its own name, which is the name that determines which TourHintable components will show the popover.

Another property of note is waitsOn. Like you’d expect, it waits on the user to do certain things before continuing to the next step. In fact, this is the only way we do guided tours in our app at the moment. I’ll talk about this later in this article.

The store

For the TourHintable component to work the way I wanted it to work, we’d need each step to have its own name, and we need some global state to keep track of which step is currently active.

You could probably do this via React’s context, but because we already have state management in the form of Altjs (the codebase originates in 2016), we put it in there.

The store and its actions take care of starting a tour and advancing through it when necessary. It keeps track of which tours are currently active, and exposes an array of the current active steps. Each TourHintable component then keeps track of this array, and if one of the steps’ name matches the component’s step prop, it will activate.

This is a simplified version of what’s in the app:

export class ToursStore {
  currentTours = [];
  currentSteps = [];

  handleStart(name) {
    const tour = availableTours[name];
    this.currentTours.push({ ...tour, currentStep: 0 });

    this.setCurrentStep();
  }

  // I'll explain this later
  handleTriggerEvent(eventSymbol) {}

  // These two basically call updateTourStep
  handleAdvanceTour(name) {}
  handlePreviousStep(name) {}

  private updateTourStep(name, increment) {
    const tourIndex = this.currentTours.findIndex(t => t.name === name);

    let tour = this.currentTours[tourIndex];
    const newStep = tour.currentStep + increment;

    if (newStep < 0) {
      // Do nothing
    } else if (tour.steps.length === newStep) {
      tour.onComplete?.();
      this.currentTours = _.without(this.currentTours, tour);
    } else {
      tour = this.currentTours[tourIndex] = {
        ...tour,
        currentStep: newStep,
      };

      // Our store-based hooks and container components use shallow
      // equality checks, so we consider this.currentTours immutable
      this.currentTours = [...this.currentTours];
    }

    this.setCurrentStep();
  }

  private setCurrentStep() {
    this.currentSteps = this.currentTours.map(tour => {
      const step = tour.steps[tour.currentStep];
      this.track(step.name);
      return step as any;
    });
  }

Waiting for events

Most guided tour solutions have a way of directly telling the tour to advance in response to a state change or with an API.

We wanted to be specific — rather than just indiscriminately calling advance() when the user does something we might be waiting for, so we added some named events that we trigger when certain things happen in the app. They’re just a bunch of constants in an exported object.

export const tourEvents = {
  openFrameEditor: Symbol(),
  importFromImageLibrary: Symbol(),
  // …
}

Using the event names like this gives some good feedback when using Intellisense or similar IDE features. I used symbols here, but I’ll likely change this to strings, which would make it possible to persist tour state properly in localStorage at a later stage.

Throughout different parts of the app, we then trigger these events:

ToursActions.triggerEvent(tourEvents.openFrameEditor);

When an event is triggered, the tour store figures out if any tours are waiting on that event, and advance that tour if it is.

handleTriggerEvent(eventSymbol: symbol) {
  this.currentTours.forEach(tour => {
    if (tour.steps[tour.currentStep]?.waitsOn === eventSymbol) {
      this.handleAdvanceTour(tour.name);
    }
  });
}

onActivate / onDeactivate

When implementing the tours, we quickly stumbled upon the requirement that we wanted to interact with parts of the app when certain steps became active.

Let’s say you have a menu that you want to open when a step becomes active. Often, this state is contained within the component itself or its parent component. There typically is no need to put it in a global store. Because this context is not accessible to any callbacks you’d add to a step’s definition, it’s useful to be able to execute a callback from the component in which TourHintable is used.

That’s why the TourHintable component has two props, onActivate and onDeactivate; functions that will be called when a specific instance activates 3.

TypeScript

For clarity’s sake, I’ve removed a lot of TypeScript syntax from the code above. The tours in Boords are actually typesafe. This gives me lovely feedback in my IDE and ensures that we don’t ship a hintable component that refers to a step that does not exist.

intellisense
Automatic suggestions for the TourHintable component in Visual Studio Code

I realise some of these steps might feel a bit convoluted, and perhaps there are better ways of doing this. At the moment, I can’t think of any.

Step names

To make the step names typesafe, we had to make types for each step’s name. Ideally, we’d just be able to and automatically create a type of all the step names defined in the tour’s definition. There are ways of turning arrays into types, but for various reasons, I couldn’t find a way to make it work without making the code harder to deal with.

I found a decent compromise by defining the steps in a type, and passing that to the TourBlueprint interface, which will use it to limit the step’s names.

export type exportTourAvailableSteps = 'openAnimaticTool' | 'someOtherStep'

export const exportTour: TourBlueprint<exportTourAvailableSteps> = {
  name: 'export',
  steps: [
    {
      name: 'openAnimaticTool',
      waitsOn: tourEvents.openAnimatic,
      // header: `''
      // body: '',
    },
  ]
}

// For reference, here's the interfaces used (normally in another file)
export interface TourBlueprint<StepNames = string> {
  readonly name: string;
  readonly steps: Readonly<TourStep<StepNames>[]>
  readonly onComplete?: () => void;
}

export interface TourStep<Name = string> {
  readonly name: Name;
  readonly header?: string,
  readonly body?: string
  readonly waitsOn?: symbol;
}

Then, in another file, we combine the steps from the different tours into one type. This type is then used to check the step prop on TourHintable.

export type allTourSteps = editingTourAvailableSteps | organisationTourAvailableSteps

With the current approach, this could lead to the situation in which two tours have a step with the same name, which might lead to unexpected behaviour. I could turn the step names into constants in a similar fashion as the events, but I also like to be able to keep the step names in one file.

So far, this hasn’t lead to any problems, but its something I’m aware of. I’m not sure what the best solution here is.

Tour & event names

When we trigger a tour, we use Altjs’s actions like this:

TourActions.start('export')

In the store’s file, we have an object that we use look up the tours by identifier, we just have to create a type from the keys in this object:

import { editingTour } from '../../tours/editingTour';
const availableTours = {
  editing: editingTour,
} as const;

type availableTourNames = keyof typeof availableTours;

We do a similar thing to make events typesafe:

export const tourEvents = {
  openFrameEditor: Symbol(),
  importFromImageLibrary: Symbol(),
  // …
}

export type availableTourEvents = keyof typeof tourEvents;

By its nature, Altjs makes it hard to be typesafe, but we found a way of making the actions and store methods typesafe enough to do these things.

Performance

For one of the tours in our app, we wanted to highlight the first element in a list. Considering that this component can repeat up to 250 times and exists in a part of the app that isn’t as render-efficient as it could be, it was important to think of performance impact here. We would not want to attach listeners for the 249 times that we do not need the actual hint.

Conditionally wrapping the target element like this would not be acceptable:

// This code smells. We're duplicating the button code here.
// Real life code isn't this simple, so we wouldn't want future us to
// accidentally overlook the second instance of button
const nope = (props) => (
    props.index === 0 ? (
      <TourHintable step="stepName">
        <button>Hello</button>
      </TourHintable>
    ) : (
      <button>Hello</button>
    )
)

To prevent this, we internally wrap the component with the store hooks and popover code in another component that makes sure the canShow prop is true. If it isn’t we don’t render the component with the hooks:

// This code is better
const yes = (props) => (
  <TourHintable step="stepName" canShow={props.index === 0}>
    <button>Hello</button>
  </TourHintable>
)

Plans

Compared to the other libraries I’ve mentioned, our approach has less features, but it was never meant to cover all of them. It’s not meant to be a fully featured alternative.

Because it’s quite integrated with our codebase, it would take some work to extract it into a stand-alone open source project. It could definitely be used as a blueprint for an open source project. I wouldn’t mind throwing some other brains at the problem.

Thanks to James Chambers, who — besides being my boss and the designer for this feature — offered feedback on the article.

  1. Adding CSS class names to components purely to attach JavaScript behaviour to them is a bit smelly, and feels particularly weird in a React app, where you would typically attach event listeners directly to React elements. If a reference to an element is necessary for some extra DOM work, Refs do the trick. In any case, you would not query for the HTML of a component that is a completely different file.
  2. The approach with selectors raises another question: what if the component matching the selector isn’t present yet? For example if it’s in a part of the application that’s still loading. This last part was important for Boords, where we instruct users to open the Frame Editor, which needs to load for a second before its UI becomes visible and we can attach the next step’s popover to a button.
  3. I’ve considered making a React Hook specifically for this behaviour ( useOnStepActivate or something), but the bulk of the components in which this functionality is needed are still old-school Class components which don’t support React hooks.