Tutorial: Multi-stage wizard in React, using Context

Last updated on June 2022

Modern web apps often have a need for a multi stage wizard. This is a guide on how to build it with React, using context.

This tutorial goes over a simple version to implement in React.

It supports:

  • unlimited number of stages, which you can set up in a easy to compose way
  • prev/next buttons
  • go to a specific stage
  • can share some data (state) between stages. e.g. form details

For a basic working demo go here.

Here is a condensed version (view the full demo for all the feature described in this post)

Wizard demo - stage 1 /

Stage 1

The final API we're looking to acheive with this Wizard

I'm going to go through step by step to build up a wizard system. I wanted the following behaviours:

  • Simple way to compose the wizard
  • Ability to pass in as many stages as I wanted. They can be any component.
  • These stages can optionally also have a skip to page X button
  • Ability to easily share state between stages (useful for forms)
  • Previous/next buttons
  • On first stage the previous button is disabled. On last stage the next button is disabled. So we must know how many stages are in the wizard.

The following code show how the wizard will be used once set up. You wrap everything in a <WizardContainer>, and your stages in <WizardStages>. Note the Stage1 etc can be any component or named anything you want.

this is just to show the end result - don't copy/paste this yet, we will build it up step by step

const YourPageOrComponent = () => (
    <>
      <WizardContainer>
        <CurrentStage />

        <WizardStages>
          <Stage1 /> {/* These can be any components, named anything */}
          <AnotherStage />
          <FinalStage />
        </WizardStages>

        <WizardPrev />
        <WizardNext />
      </WizardContainer>
    </>
);

We have a WizardContainer component that contains:

  • <CurrentStage> - shows the current progress ('you are on stage 1 of 3')
  • <WizardStages> accepts any number of children. They can be any kind of component, and it counts the number of children to know how many stages there are
  • and a couple of buttons for prev/next (<WizardPrev>/<WizardNext>).

As there is going to be some shared state (both the data we are sharing in any forms in each stage, but also the current stage, number of stages etc) a nice way to do it is with a context.

Let's begin by making a new context

Create a new context, and a helper (useWizardContext).

This context value will have the following items:

  • state/setState arbitrary value that you can use to share data between stages. In my example it is just a string (from the email form on stage 1)
  • numStages the number of children in WizardStages
  • setNumStages used by WizardStages to set the number of children
  • currentStage the current state (starts at 1, not 0)
  • handleGoToStage - a function to go to a specific stage (e.g. ctx.handleGoToStage(2) to go to 2nd stage
  • handleNext a function to go to the next stage
  • handlePrev a function to go to the previous stage
const WizardContext = React.createContext();

// helper function to get context
function useWizardContext() {
  return useContext(WizardContext);
}

Create the outer container

This is the outer component. The main purpose of it is to set up the context and initial state values.

function WizardContainer({ children }) {
  const [numStages, setNumStages] = useState();
  const [currentStage, setCurrentStage] = useState(1); // start at 1
  const [state, setState] = useState();

  const ctx = {
    state,
    setState,
    numStages,
    setNumStages,
    currentStage,
    handleGoToStage: (stage) => setCurrentStage(stage),
    handleNext: () => setCurrentStage((stage) => stage + 1),
    handlePrev: () => setCurrentStage((stage) => stage - 1),
  };
  return (
    <WizardContext.Provider value={ctx}>
      {children}
    </WizardContext.Provider>
  );
}

Component to show the current stage

We can use useWizardContext() to get the context, and from that we can get the currentStage and numStages. If we use it with the components built already, it will show up as 0 stages. We will set the number of stages soon.

function CurrentStage() {
  const { currentStage, numStages } = useWizardContext();

  return (
    <h3>
      Viewing stage {currentStage} / {numStages}
    </h3>
  );
}

The container for the stages

Now it is time to create our <WizardStages> component. Inside of this is where we compose the list of stages.

These (child) stages can be any component. They don't have to implement any logic relating to this wizard system. But if they want to, they can use the wizard context (e.g. to handle a button click to go to a specific stage).

This is where we take the children prop, and use React.Children.count() to see how many there are. This is used for the number of stages.

We use a useEffect() call to update numStages on the context every time the number of children changes. Which for our demo, is just once, on initial load.

Then is uses React.Children.toArray on the children (turning our list of <Stage1 /><Stage 2 /><Stage3/> components into an array) and picking the currentStage index.

Then it just returns that one single stage (current stage).


// container for multiple children, each one representing a stage
function WizardStages({ children }) {
  const { currentStage, setNumStages } = useWizardContext();

  const childrenCount = React.Children.count(children);

  // set num of stages, based on num of children passed in
  useEffect(
      () => setNumStages(childrenCount),
      [childrenCount, setNumStages]
  );

  // get currently selected stage
  const currentStageComponent = 
      React.Children.toArray(children)[currentStage - 1];

  // and just return that one single current stage
  return currentStageComponent
}

Set up wizard buttons (prev/next)

These buttons use the context to find out the current stage and pass in the onClick handlers.

// previous button
function WizardPrev() {
  const { currentStage, handlePrev } = useWizardContext();

  // disabled if cannot go back
  if (currentStage === 1) return null;

  return (
    <button onClick={handlePrev}>
      Previous
    </button>
  );
}

// next button
function WizardNext() {
  const { currentStage, handleNext, numStages } = useWizardContext();

  // disabled if cannot go forward
  if (currentStage > numStages - 1) return null;

  return (
    <button onClick={handleNext}>
      Next
    </button>
  );
}

Let's add some dummy stages

Now let's set up some dummy stages. For this demo I'll add 3 stages.

The first will have a form input, and onChange will set the state (from useWizardContext())

Note: I'm storing the value (a string) directly on the state. You would probably want to store various attributes on an object, or use something more suitable (like useReducer). This is just a simple demo.

It will also have a button that when clicked will call handleGoToStage(3) (note: stages start at index 1)

Stage 2 is just a normal plain component, no use of the wizard context.

The third stage will show the entered data from the form input from stage 1.

// Stages - these can contain anything
function Stage1() {
  const { handleGoToStage, setState } = useWizardContext();
  return (
    <div>
      <h2>Stage 1</h2>

      <input
        type="text"
        name="email"
        onChange={(e) => setState(e.currentTarget.value)}
      />

      <button onClick={() => handleGoToStage(3)}>
        Skip to stage 3
      </button>
    </div>
  );
}

function Stage2() {
  return (<h2>Stage 2</h2>);
}

function Stage3() {
  // get the state so we can see the email that was entered on stage 1
  // note: for this demo, a string is stored on `state`. You would
  // probably store an object and have more complex rules here.  
  const { state } = useWizardContext();
  return (
    <div>
      <h2>Stage 3</h2>
      <p>
        Your email, from first stage: <b>{state}</b>
      </p>
    </div>
  );
}

Now put it all together and set up the wizard

And that is everything you need for a simple wizard in React, using context and passing state data between each stages.

const YourPageOrComponent = () => (
    <div>
      <h2>Wizard example</h2>

      <WizardContainer>
        <CurrentStage />

        <WizardStages>
          <Stage1 />
          <Stage2 />
          <Stage3 />
        </WizardStages>

        <WizardPrev />
        <WizardNext />
      </WizardContainer>
    </div>
);

If you are building this for a production app, then there are a few things to consider...

  • You probably want to make it accessible
  • handle validation on current state before changing pages
  • state management (of the form input in my demo) is pretty basic
  • Use URL/query params to be able to share links to specific stages (might not work for all use cases)
  • Show progress, show buttons to skip to each stage
  • On the final stage you probably would want some kind of 'submit' or 'finish' button to finish the wizard

Full source code

import React, { useContext, useEffect, useState } from 'react';

const WizardContext = React.createContext();

// helper function to get context
function useWizardContext() {
  return useContext(WizardContext);
}

function WizardContainer({ children }) {
  const [numStages, setNumStages] = useState();
  const [currentStage, setCurrentStage] = useState(1); // start at 1
  const [state, setState] = useState();

  const ctx = {
    state,
    setState,
    numStages,
    setNumStages,
    currentStage,
    handleGoToStage: (stage) => setCurrentStage(stage),
    handleNext: () => setCurrentStage((stage) => stage + 1),
    handlePrev: () => setCurrentStage((stage) => stage - 1),
  };
  return (
    <WizardContext.Provider value={ctx}>
      {children}
    </WizardContext.Provider>
  );
}

function CurrentStage() {
  const { currentStage, numStages } = useWizardContext();

  return (
    <h3>
      Viewing stage {currentStage} / {numStages}
    </h3>
  );
}

// container for multiple children, each one representing a stage
function WizardStages({ children }) {
  const { currentStage, setNumStages } = useWizardContext();

  const childrenCount = React.Children.count(children);

  // set num of stages, based on num of children passed in
  useEffect(
      () => setNumStages(childrenCount),
      [childrenCount, setNumStages]
  );

  const currentStageComponent = 
      React.Children.toArray(children)[currentStage - 1];

  return currentStageComponent;
}

// previous button
function WizardPrev() {
  const { currentStage, handlePrev } = useWizardContext();

  // disabled if cannot go back
  if (currentStage === 1) return null;

  return (
    <button onClick={handlePrev}>
      Previous
    </button>
  );
}

// next button
function WizardNext() {
  const { currentStage, handleNext, numStages } = useWizardContext();

  // disabled if cannot go forward
  if (currentStage > numStages - 1) return null;

  return (
    <button onClick={handleNext}>
      Next
    </button>
  );
}

// Stages - these can contain anything
function Stage1() {
  const { handleGoToStage, setState } = useWizardContext();
  return (
    <div>
      <h2>Stage 1</h2>

      <input
        type="text"
        name="email"
        onChange={(e) => setState(e.currentTarget.value)}
      />

      <button onClick={() => handleGoToStage(3)}>
        Skip to stage 3
      </button>
    </div>
  );
}

function Stage2() {
  return (
    <div>
      <h2>Stage 2</h2>
      <p>Nothing to see here, continue to your next stage in this wizard</p>
    </div>
  );
}

function Stage3() {
  // get the state so we can see the email that was entered on stage 1
  const { state } = useWizardContext();
  return (
    <div>
      <h2>Stage 3</h2>

      <p>
        Your email, from first stage: <b>{state}</b>
      </p>
    </div>
  );
}

export function WizardPage() {
    return (
        <div>
            <h2>Wizard example</h2>

            <WizardContainer>
                <CurrentStage />

                <WizardStages>
                    <Stage1 />
                    <Stage2 />
                    <Stage3 />
                </WizardStages>

                <WizardPrev />
                <WizardNext />
            </WizardContainer>
        </div>
    );
}

For a basic working demo go here.

© 2019-2023 a5h.dev.
All Rights Reserved. Use information found on my site at your own risk.