Contents
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 credit: https://www.appcues.com
Let's outline requirements to create a similar tutorial:
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/>}
...
</>This could turn into a working solution, however you might notice a couple of disadvantages of this approach:
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:
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: () => {},
});
...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.
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>
);
};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>
);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 TutorialPopoverSince 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;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;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 TutorialPopoverNow 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;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;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;That's it! We now have our observer looking for container element and tutorial starting on the application load.
JSFiddle sample code
GitHub source code
Happy coding!