Streamlining React Form Navigation: A Comprehensive Guide to Crafting a Performant Wizard Interface from Scratch

TL;DR:

CodeDemo

Introduction

Creating forms with “wizard-like” behavior has become increasingly common in web development, especially for processes such as Know Your Customer (KYC) flows and subscription sign-ups.

These forms guide users through steps or stages, making complex data entry tasks more manageable and user-friendly. By breaking down the process into smaller, sequential steps, wizard forms improve the user experience, reduce cognitive load, and help ensure that users complete the form accurately and efficiently.

But how do you manage that as a React Front-end Developer?

The problem

If you use a stepper component from a library like MUI, the logic will be left to implement. Also, you will probably want that to be abstracted independently in a separate hook rather than having something like the code shown in their documentation because, you know, clean code, separation of concerns, and other stuff.

On the other hand, if you go for npm libraries like react-use-wizard, most do not support navigation. These libraries typically provide basic next and previous buttons, leaving you to figure out the rest without much option to extend them.

I mean, it would be nice to have something like this:

const SteppedWizard = () => {
return (
<WizardRoot>
<WizardNavigation/>
<WizardSteps/>
</WizardRoot>
);
};

export default SteppedWizard;

Where the WizardNavigation is a standalone component that you can choose how it behaves and looks, as well as take advantage of the shared namespace to make it a much smarter wizard. We can use the Wizard to share data across the components to render different things conditionally. That would be nice to have, too!

The approach

The approach is to create a top-level context provider that supplies the data and API. The nested children will access those elements through the React Context API.

import React, {useContext} from ‘react’;
import {WizardDataContext, WizardDataProvider} from ‘./WizardDataContext.js’;
import {WizardAPIContext, WizardAPIProvider} from ‘./WizardAPIContext.js’;
import {useWizard} from ‘./useWizard.js’;

export const WizardRoot = ({steps: originalSteps = [], onComplete, children}) => {
const {data, api} = useWizard({originalSteps, onComplete});

return <>
<WizardDataProvider value={data}>
<WizardAPIProvider value={api}>
{children}
</WizardAPIProvider>
</WizardDataProvider>
</>;
};

export const useWizardAPI = () => useContext(WizardAPIContext);
export const useWizardData = () => useContext(WizardDataContext);

Usage in component:

const ButtonGroup = () => {
const {isCurrentStepLastStep, isCurrentStepFirstStep} = useWizardData();
const {moveToPreviousStep, moveToNextStep, onComplete} = useWizardAPI();

return (
<div className={styles.buttonGroup}>
{!isCurrentStepFirstStep && <button
className={styles.button}
onClick={moveToPreviousStep}
>
Back
</button>}
<button
className={styles.button}
onClick={isCurrentStepLastStep ? onComplete : moveToNextStep}
>
{isCurrentStepLastStep ? ‘Finish’ : ‘Next’}
</button>
</div>
);
};

Doesn’t it look clean and beautiful?

Implementation

Using Context will allow us to consume any part of the data at any point in the tree, from the navigation panel to the steps.

Also, this pattern allows for an enormous number of variants. You can add methods for active or inactive steps to track the progress of each step, such as an onStart callback, onFinish, or onStepFinished callback. I am not implementing all of them for simplicity; I will prototype just a few.

Another important point to stand out is the existence of an extraData variable. Since our steps will share a common namespace, this variable is an amazing place to store data that might be related to the form’s flow.

For example, let’s say you are ordering a book and choosing the paper version. If you chose the digital version, a step to “shipping address” must be skipped. You can use the extraData to store values from previous steps and create variants based on that information.

You can also set a method to inactivate a specific step based on certain conditions. You can add to your steps array properties like shouldShowStep title or subtitle. You can have more functions too! The freedom is there; you already have the pattern that makes it possible.

Let’s take a look at the data that will be provided through the context:

import React from ‘react’;

type StepStatus = “notStarted” | “inProgress” | “completed”;

type Step = {
id: string;
component: React.FC;
status: StepStatus;
};

type WizardData = {
currentStepId: string;
currentStepIndex: number;
isCurrentStepLastStep: boolean;
isCurrentStepFirstStep: boolean;
currentStepStatus: StepStatus;
stepsLength: number;
steps: Step[];
currentStepComponent: React.FC;
extraData: Record<string, any>;
};

type WizardAPI = {
moveToPreviousStep: () => void;
moveToNextStep: () => void;
moveToStepById: (id: string) => void;
moveToStepByIndex: (index: number) => void;
onComplete: () => void;
setExtraData: (data: Record<string, any>) => void;
};

// usage in component
const {
currentStepId,
currentStepIndex,
isCurrentStepLastStep,
isCurrentStepFirstStep,
currentStepStatus,
stepsLength,
steps,
currentStepComponent,
extraData,
}: WizardData = useWizardData();

const {
moveToPreviousStep,
moveToNextStep,
moveToStepById,
moveToStepByIndex,
onComplete,
setExtraData,
}: WizardAPI = useWizardAPI();

Performance

If you paid attention to the code, you probably realized that there are two contexts involved: useWizardData() and useWizardAPI();

import _ from ‘lodash’;
import {v4 as uuidv4} from ‘uuid’;
import {useMemo, useReducer} from ‘react’;
import {currentStepReducer} from ‘./WizardReducer.js’;
import {WIZARD_API_ACTIONS} from ‘./WizardAPIContext.js’;

export const useWizard = ({originalSteps, onComplete}) => {
const mappedSteps = _.map(originalSteps, step => ({
…step,
id: step?.id || uuidv4(),
status: step?.status || ‘notStarted’
}));

const [{steps, currentStepIndex}, dispatch] = useReducer(currentStepReducer, {
steps: mappedSteps,
currentStepIndex: 0
});

const currentStep = _.get(steps, currentStepIndex);

const isCurrentStepFirstStep = currentStepIndex === 0;
const stepsLength = _.size(steps);
const isCurrentStepLastStep = stepsLength – 1 === currentStepIndex;
const currentStepComponent = currentStep?.component;
const currentStepStatus = currentStep?.status;
const currentStepId = currentStep?.id;

const data = useMemo(() => ({
steps,
currentStepIndex,
isCurrentStepLastStep,
isCurrentStepFirstStep,
stepsLength,
currentStepComponent,
currentStepStatus,
currentStepId
}), [
steps,
currentStepIndex,
currentStepId,
currentStep,
isCurrentStepLastStep,
isCurrentStepFirstStep,
stepsLength,
currentStepComponent,
currentStepStatus
]);

const api = useMemo(() => ({
moveToPreviousStep: () => dispatch({type: WIZARD_API_ACTIONS.MOVE_TO_PREVIOUS_STEP}),
moveToNextStep: () => dispatch({type: WIZARD_API_ACTIONS.MOVE_TO_NEXT_STEP}),
moveToStepById: id => dispatch({
type: WIZARD_API_ACTIONS.MOVE_TO_STEP_BY_ID,
payload: {id}
}),
moveToStepByIndex: stepIndex => dispatch({
type: WIZARD_API_ACTIONS.MOVE_TO_STEP_BY_INDEX,
payload: {stepIndex}
}),
onComplete,
setExtraData: extraData => dispatch({
type: WIZARD_API_ACTIONS.SET_EXTRA_DATA,
payload: {extraData}
})
}), []);

return {api, data};
};

The split is for performance reasons. The core of the component memoizes those values independently to avoid unwanted rerenders.

Usually, you can see useState in the core of custom hooks. But in this case, I chose to use useReducer, which makes the hook’s functions 100% independent of the state, avoiding recreating them on every data change.

This is a technique I learned from Nadia Makarevic, “Advanced React” (pages 156–160) if you are interested in reading more of it.

Behavior and implementation

Having all these features right out of the box means your logic can live independently from the UI — and that’s the sweet spot we’re aiming for

Behavior: In this Wizard, I can jump between steps; the last step has a “Finish” button, and the first step lacks Back. The Wizard itself provides all the elements my UI requires, and what it is not, it is easy to extend. Do you want to remove the navigation? No problem, just unmount the <WizardNavigation/> component.

Wrapping up

In this guide, we’ve explored how to build a performant React form wizard with stepped navigation from scratch. By leveraging React’s Context API and a custom hook, we’ve crafted a flexible and scalable solution that separates logic from presentation, ensuring a clean and maintainable codebase.

By abstracting the wizard’s logic, the possibility to add new properties to the steps and create functions around them, and providing an extraData variable, you can compose more than just simple wizards.

Feel free to explore and extend the example code to suit your requirements.

Happy coding!

Creating a Performant React Form Wizard with Stepped Navigation: A Comprehensive Guide was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

​ Level Up Coding – Medium

about Infinite Loop Digital

We support businesses by identifying requirements and helping clients integrate AI seamlessly into their operations.

Gartner
Gartner Digital Workplace Summit Generative Al

GenAI sessions:

  • 4 Use Cases for Generative AI and ChatGPT in the Digital Workplace
  • How the Power of Generative AI Will Transform Knowledge Management
  • The Perils and Promises of Microsoft 365 Copilot
  • How to Be the Generative AI Champion Your CIO and Organization Need
  • How to Shift Organizational Culture Today to Embrace Generative AI Tomorrow
  • Mitigate the Risks of Generative AI by Enhancing Your Information Governance
  • Cultivate Essential Skills for Collaborating With Artificial Intelligence
  • Ask the Expert: Microsoft 365 Copilot
  • Generative AI Across Digital Workplace Markets
10 – 11 June 2024

London, U.K.