Contents
- Creating context
- Providing context to manage tutorial progression and status
- Creating popover component
- Conditionally displaying popover
- createPortal
- 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 credit: https://www.appcues.com
Let's outline requirements to create a similar tutorial:
- Tutorial should be presented on loading of the application.
- It should consist of multiple steps, each highlighting specific feature of the app.
- 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/>}
...
</>
This could turn into a working solution, however you might notice a couple of disadvantages of this approach:
- 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.
- 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: () => {},
});
...
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>
);
};
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>
);
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
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;
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;
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
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;
6. Caveats
- 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.
- 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;
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!
Top comments (0)