DEV Community

Cover image for Utilising createPortal to build walkthrough experiences in React applications
Kate
Kate

Posted on • Originally published at katepurchel.com

Utilising createPortal to build walkthrough experiences in React applications

Contents

  1. Creating context
  2. Providing context to manage tutorial progression and status
  3. Creating popover component
  4. Conditionally displaying popover
  5. createPortal
  6. Caveats

If you've just finished adding new features to your app and want to showcase them to your users, adding a quick tutorial might help them to get up-to-speed.

One of the best ways to give users a quick know-how is by highlighting elements of the app and providing short description of its functionality:

image
Image credit: https://www.appcues.com
 

Let's outline requirements to create a similar tutorial:

  1. Tutorial should be presented on loading of the application.
  2. It should consist of multiple steps, each highlighting specific feature of the app.
  3. Tutorial should automatically close on completion.

Taking into account everything above, we might come up with something like this:

// pseudocode

const [step, setStep] = useState<number>(0)

useEffect(() =>{
    setStep(step === 3 ? 0 : step + 1)
}, [])

<>
    {step === 1 && <StepOne/>}
    {step === 2 && <StepTwo/>}
    {step === 3 && <StepThree/>}
    ...
</>
Enter fullscreen mode Exit fullscreen mode

This could turn into a working solution, however you might notice a couple of disadvantages of this approach:

  1. Tutorial steps will be displayed exactly right where you put them in the component tree, and not close enough to relevant elements that we want to highlight, making tutorial not as helpful.
  2. Even if we put tutorial popovers near existing elements in the code, those elements will become coupled, making refactoring harder in case we need to move elements around.

This is where createPortal comes in. Added in React 16, createPortal function allows us to insert element into different parts of the DOM. In the essence, we can find element in the DOM and attach React element to it.

Let's see how it works on practice with our tutorial.

A few things to note before we start:

  • Sample below assumes existence of working React 16 app or higher. We will use dashboard template provided by Material UI as a backdrop for our popups.
  • Basic knowledge of React and Material UI library is assumed.

 

1. Creating context

Let's do some groundwork first. This step might be optional but advisable as to avoid prop drilling. This is especially important if tutorial trigger and tutorial steps are separated by multiple levels of nested components.

In this example we'll be using React Context API, however other state management tools such as Redux can also be used.

// src/providers/TutorialProvider.tsx
import { createContext } from 'react';

...
export const TutorialContext = createContext({
    tutorialOpen: false,
    currentStep: tutorialSteps[0],
    onTutorialContinue: () => {},
    toggleTutorial: () => {},
});

...
Enter fullscreen mode Exit fullscreen mode

We want to store tutorial status, current tutorial step, function to navigate to the next tutorial step and a function to toggle tutorial on/off.
 

2. Providing context to manage tutorial progression and status

Let's add new TutorialProvider that will allow us sharing context state down the component tree of our application.

// src/providers/TutorialProvider.tsx
import { createContext } from 'react';

export type TutorialStep = {
    id: number;
    elementId: string;
    tip: string;
};

const tutorialSteps: TutorialStep[] = [
    {
        id: 1,
        elementId: 'home',
        tip: 'Welcome to the new dashboard!',
    },
    {
        id: 2,
        elementId: 'reports',
        tip: 'You can view your reports here',
    },
    {
        id: 3,
        elementId: 'notifications',
        tip: 'You will receive notification once order is completed',
    },
    {
        id: 4,
        elementId: 'recentOrders',
        tip: 'Your orders will be listed here',
    },
];

export const TutorialContext = createContext({
    tutorialOpen: false,
    currentStep: tutorialSteps[0],
    onTutorialContinue: () => {},
    toggleTutorial: () => {},
});

export const TutorialProvider = ({ children }: { children: ReactNode }) => {
  const [tutorialOpen, setTutorialOpen] = useState<boolean>(false);
  const [currentStep, setCurrentStep] = useState<TutorialStep>(
    tutorialSteps[0]
  );

  const onTutorialContinue = useCallback(() => {
    const currentStepIndex = tutorialSteps.indexOf(currentStep);
    const isLastStep = currentStepIndex === tutorialSteps.length - 1;
    setTutorialOpen(!isLastStep);
    setCurrentStep(
      isLastStep ? tutorialSteps[0] : tutorialSteps[currentStepIndex + 1]
    );
  }, [currentStep]);

  const toggleTutorial = (): void => {
    setTutorialOpen(!tutorialOpen);
  };

  return (
    <TutorialContext.Provider
      value={{ tutorialOpen, currentStep, onTutorialContinue, toggleTutorial }}
    >
      {children}
    </TutorialContext.Provider>
  );
};

Enter fullscreen mode Exit fullscreen mode

NOTE: Example above uses hardcoded step values for the simplicity purposes however fetching these steps from API might work better depending on your app's architecture.

It's important to wrap provider around the <App /> so we can actually use it:

// src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { TutorialProvider } from './providers/TutorialProvider';

ReactDOM.createRoot(document.getElementById('app')).render(
  <React.StrictMode>
    <TutorialProvider>
      <App />
    </TutorialProvider>
  </React.StrictMode>
);

Enter fullscreen mode Exit fullscreen mode

 

3. Creating popover component

Let's now create popover component itself. Material UI provides an excellent library of out-of-the-box components that could be used to build a simple popover with a backdrop.

// src/components/TutorialPopover.tsx

import React, { useCallback, useContext, useState } from 'react'
import { TutorialContext } from '../providers/TutorialProvider';
import { Backdrop, Box, Button, Stack, Tooltip, Typography } from '@mui/material';

const TutorialPopover = () => {
    const { currentStep, onTutorialContinue } = useContext(TutorialContext);

    return (
        <Backdrop open>
            <Tooltip open title={
                <Stack>
                    <Typography variant='body2'>
                        Tip #{currentStep.id}
                    </Typography>
                    <Typography>
                        {currentStep.tip}
                    </Typography>
                    <Button variant='contained' onClick={onTutorialContinue}>
                        Got it
                    </Button>
                </Stack>
            }>
                <Box></Box>
            </Tooltip>
        </Backdrop>
    )
}

export default TutorialPopover
Enter fullscreen mode Exit fullscreen mode

Since we want our tutorial to run on the highest level of the application and highlight different elements across the app, let's add our newly created component to the root of the application:

// src/App.tsx

import * as React from 'react';
import { TutorialContext } from './providers/TutorialProvider';
import { useContext, useEffect } from 'react';
import TutorialPopover from './components/TutorialPopover';

function App() {
  return (
    <ThemeProvider theme={defaultTheme}>
      ...
      // Adding popover
      <TutorialPopover />
    </ThemeProvider>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

We can now use TutorialContext created above and trigger our tutorial from inside useEffect hook in the App. This way tutorial will start on application load.

// src/App.tsx

import * as React from 'react';
import { TutorialContext } from './providers/TutorialProvider';
import { useContext, useEffect } from 'react';
import TutorialPopover from './components/TutorialPopover';

function App() {
  const { toggleTutorial } = useContext(TutorialContext);

  const [open, setOpen] = React.useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  useEffect(() => {
    toggleTutorial()
  }, [])

  return (
    ...
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

NOTE: example above is simplified to display tutorial every time our application loads. In the real-world scenario tutorial will most likely be triggered as a result of API call to your backend.

 

4. Conditionally displaying popover

We want our popover to only show when it is set to open in our context. When tutorial is started from the useEffect hook of App, we'll get the status update in the TutorialPopover component.

// src/components/TutorialPopover.tsx

import React, { useCallback, useContext, useState } from 'react'
import { TutorialContext } from '../providers/TutorialProvider';
import { Backdrop, Box, Button, Stack, Tooltip, Typography } from '@mui/material';

const TutorialPopover = () => {
    const { tutorialOpen, currentStep, onTutorialContinue } = useContext(TutorialContext);

    // only show tutorial when it is set as open in our context provider
    if (!tutorialOpen) return null

    return (
        <Backdrop open>
            <Tooltip open title={
                <Stack>
                    <Typography variant='body2'>
                        Tip #{currentStep.id}
                    </Typography>
                    <Typography>
                        {currentStep.tip}
                    </Typography>
                    <Button variant='contained' onClick={onTutorialContinue}>
                        Got it
                    </Button>
                </Stack>
            }>
                <Box></Box>
            </Tooltip>
        </Backdrop>
    )
}

export default TutorialPopover
Enter fullscreen mode Exit fullscreen mode

 

5. createPortal

Now the the fun part! We're utilising createPortal to display popover right in the container component by using element id. <Tooltip> is the component that will be attached to the component found by its id.

// src/components/TutorialPopover.tsx

import React, { useCallback, useContext, useState } from 'react';
import { TutorialContext } from '../providers/TutorialProvider';
import {
  Backdrop,
  Box,
  Button,
  Stack,
  Tooltip,
  Typography,
} from '@mui/material';
import { createPortal } from 'react-dom';

const TutorialPopover = () => {
  const { tutorialOpen, currentStep, onTutorialContinue } =
    useContext(TutorialContext);

  if (!tutorialOpen) return null;

  return (
    <Backdrop open>
      {document.getElementById(currentStep.elementId) &&
        createPortal(
          <Tooltip
            open
            title={
              <Stack>
                <Typography variant="body2">Tip #{currentStep.id}</Typography>
                <Typography>{currentStep.tip}</Typography>
                <Button variant="contained" onClick={onTutorialContinue}>
                  Got it
                </Button>
              </Stack>
            }
          >
            <Box></Box>
          </Tooltip>,
          document.getElementById(currentStep.elementId) as Element
        )}
    </Backdrop>
  );
};

export default TutorialPopover;
Enter fullscreen mode Exit fullscreen mode

 

6. Caveats

  1. We have to be mindful of elements' id values. In case of multiple ids found in DOM, child element will attach to the first instance found. One solution to this would be assigning unique ids to your elements.
  2. On the other hand, if element is not found, our child element won't be rendered so we have to make sure container element exists in DOM before trying to call createPortal.

To address second point, we will be monitoring DOM readiness by using useState and useMutation hooks in our tutorial component and passing element id to observer to watch out for. Once it has been registered in the DOM, DOMReady value will be updated.

Creating observer hook:

// src/hooks/useMutationObserver.ts

import { useEffect, useState } from 'react';

const DEFAULT_OPTIONS = {
  config: { attributes: true, childList: true, subtree: true },
};

function useMutationObservable(
  target: Element,
  callback: MutationCallback,
  options = DEFAULT_OPTIONS
) {
  const [observer, setObserver] = useState<MutationObserver | null>(null);

  useEffect(() => {
    const obs = new MutationObserver(callback);
    setObserver(obs);
  }, [callback, options, setObserver]);

  useEffect(() => {
    if (!observer) return;
    const { config } = options;
    observer.observe(target, config);
    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [observer, options, target]);
}

export default useMutationObservable;

Enter fullscreen mode Exit fullscreen mode

Now we have to update our TutorialPopover:

// src/components/TutorialPopover.tsx

import React, { useCallback, useContext, useState } from 'react';
import { TutorialContext } from '../providers/TutorialProvider';
import {
  Backdrop,
  Box,
  Button,
  Stack,
  Tooltip,
  Typography,
} from '@mui/material';
import { createPortal } from 'react-dom';
import useMutationObserver from '../hooks/useMutationObserver';

const TutorialPopover = () => {
  const { tutorialOpen, currentStep, onTutorialContinue } =
    useContext(TutorialContext);
  const [DOMReady, setDOMReady] = useState(false);

  const onListMutation = useCallback(() => {
    setDOMReady(!!document.getElementById(currentStep.elementId));
  }, [currentStep.elementId]);

  // observing DOM for the element to which tutorial popover will be attached
  useMutationObserver(document.body, onListMutation);

  // we will not display tutorial unless container element is rendered in the DOM
  if (!tutorialOpen || !DOMReady) return null;

  return (
    ...
  );
};

export default TutorialPopover;
Enter fullscreen mode Exit fullscreen mode

 

That's it! We now have our observer looking for container element and tutorial starting on the application load.

Create portal tutorial

JSFiddle sample code
GitHub source code

Happy coding!

 

Let's connect via my Portfolio | GitHub | Codepen

Top comments (0)