Stepper component with React Hooks and Context

In this tutorial, we’re going to create a simple and easily customizable Stepper component. For the purpose of showing the Stepper component in action, I will also use the Stepper progress bar from my previous tutorial and wrap it into a Styled component. Let’s create a fresh React application

npx create-react-app react-stepper-component
npm install --save-dev styled-components

Manage state with useReducer

I will begin with the state management. I’m not going to use any 3rd party library and rather will take advantage of the built-in useReducer hook. Let’s first create the constants for our actions.

export const SET_STEPS = 'SET_STEPS';
export const INCREMENT_CURRENT_STEP = 'INCREMENT_CURRENT_STEP';
export const DECREMENT_CURRENT_STEP = 'DECREMENT_CURRENT_STEP';

Basically, we will have 3 main actions – SET_STEPS, INCREMENT_CURRENT_STEP, DECREMENT_CURRENT_STEP.

import {DECREMENT_CURRENT_STEP, INCREMENT_CURRENT_STEP, SET_STEPS} from "./constants";

export const defaultStepperState = {
    steps: [],
    currentStep: 0
};

export const reducer = (state = defaultStepperState, action) => {
    const { currentStep, steps } = state;
    const { type, payload } = action;
    switch (type) {
        case SET_STEPS:
            return {
                ...state,
                steps: payload.steps
            };
        case INCREMENT_CURRENT_STEP:
            return {
                ...state,
                currentStep:
                    currentStep < steps.length - 1
                        ? currentStep + 1
                        : currentStep
            };
        case DECREMENT_CURRENT_STEP:
            return {
                ...state,
                currentStep:
                    currentStep > 0
                        ? currentStep - 1
                        : currentStep
            };

        default:
            return state;
    }
};

Essentially, our state consists of only 2 values – steps(Array) and currentStep(Number). You can, of course, extend the reducer and add more actions but for now, these actions will be sufficient. Now, it’s time to create Context and bind it with our reducer.

Context and useStepper hook

Let’s initiate the context:

import React, {useReducer} from 'react';
import { defaultStepperState, reducer } from '../store';

export const StepperContext = React.createContext();

export const StepperProvider = ({ children }) => {
    const [state, dispatch] = useReducer(reducer, defaultStepperState);

    return (
        <StepperContext.Provider value={[state, dispatch]}>
            {children}
        </StepperContext.Provider>
    );
};

Nothing really special here, if you want to learn more about useContext, you can check the official documentation. Now it’s time for the more complicated part, the useStepper hook, which will allow us to control the state from any consumer component of newly created Context. I will add it below the StepperProvider function:

export const useStepper = () => {
    const [state, dispatch] = useContext(StepperContext);
    const { currentStep, steps } = state;

    if(!StepperContext) {
        throw new Error('useStepper should be used inside StepperProvider')
    }

    const incrementCurrentStep = useCallback(() => {
        dispatch({
            type: INCREMENT_CURRENT_STEP
        });
    }, [dispatch]);

    const decrementCurrentStep = useCallback(() => {
        dispatch({
            type: DECREMENT_CURRENT_STEP
        });
    }, [dispatch]);

    const setSteps = useCallback(steps => dispatch({ type: SET_STEPS, payload: { steps } }), [dispatch]);


    return {
        incrementCurrentStep,
        decrementCurrentStep,
        setSteps,
        currentStep,
        steps
    }
}

Here we simply create the dispatcher functions and expose them along with the state values. You’ve probably noticed that I had wrapped the functions in a useCallback. The reason for that is to avoid rerendering issues, if using these functions inside the dependency array of a useEffect function. You can read more about this here.

The Stepper component

Before we jump to the component declaration, I would like to show you, how I want our API to look like. Instead of manually settings each in an array or object, I prefer a more intuitive approach – we will just add the markup for each step and our smart component will automatically extract their meta information and store them in our state, keeping their order as well. Here’s how I want our component to be used:

<Stepper>
    <Stepper.Steps>
        <Stepper.Step id="first" name="Step 1">
            <StepBody>
                ...Content for step 1 goes here
            </StepBody>
        </Stepper.Step>
        <Stepper.Step id="second" name="Step 2">
            <StepBody>
                ...Content for step 2 goes here
            </StepBody>
        </Stepper.Step>
    </Stepper.Steps>
</Stepper>

Here are the main components that we would need(without the styled ones):

  • Stepper – this is the wrapper component, which is responsible for displaying the progress bar and the body of each step.
  • Stepper.Steps – this is the component where the magic happens. It takes each of its children and store them in our state.
  • Stepper.Step – this is where we add the markup for each step.

I must admit, naming is not my strenght, so I hope that my clarifications help you distinguish the compoentns and their purpose. Let’s start with the Stepper component, which consists of only styling and markup, without any logic attached.

import React from 'react';
import styled from 'styled-components';
import {StepperStep, StepperSteps} from './StepperSteps';
import {
    StyledStepperHeader,
    StyledStepperHeaderItem
} from './StepperHeader';
import {useStepper} from '../context';

const Stepper = ({children}) => {
    const {
        currentStep,
        steps
    } = useStepper();
    return (
        <StyledStepperContainer>
            <StyledStepperHeader>
                {steps.length ?
                    steps.map((step, index) => (
                        <StyledStepperHeaderItem
                            key={step.id}
                            className={currentStep >= index ? 'completed' : ''}
                        >
                            <div className="step-counter">{index + 1}</div>
                            <div className="step-name">{step.name}</div>
                        </StyledStepperHeaderItem>
                    )) : null}
            </StyledStepperHeader>
            <StyledStepperBody>
                {children}
            </StyledStepperBody>
        </StyledStepperContainer>)
};

Stepper.Step = StepperStep;
Stepper.Steps = StepperSteps;

const StyledStepperContainer = styled.div`
    display: flex;
    flex-direction: column;
`;

const StyledStepperBody = styled.div`
    padding: 50px 16px;
`;

export default Stepper;

As you can see, we get the state using useStepper and render it in the progress bar, inside StepperHeader which is a styled component. Here are the styles for the StepperHeader:

import styled from 'styled-components';

export const StyledStepperHeaderItem = styled.div`
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    flex: 1;

    .step-counter {
        position: relative;
        z-index: 5;
        display: flex;
        justify-content: center;
        align-items: center;
        width: 35px;
        height: 35px;
        padding: 10px;
        border-radius: 50%;
        background: #ccc;
        margin-bottom: 6px;
    }

    &::after {
        position: absolute;
        content: '';
        border-bottom: 2px solid #ccc;
        width: 100%;
        top: 17px;
        left: 50%;
        z-index: 2;
    }

    &.completed {
        .step-counter {
            background-color: #4bb543;
        }
        &::before {
            position: absolute;
            content: '';
            border-bottom: 2px solid #4bb543;
            width: 100%;
            top: 17px;
            left: -50%;
            z-index: 3;
        }
    }

    &:first-child {
        &::before {
            content: none;
        }
    }

    &:last-child {
        &::after {
            content: none;
        }
    }

    @media (max-width: 768px) {
        font-size: 12px;
    }
`;

export const StyledStepperHeader = styled.div`
    margin-top: auto;
    display: flex;
    justify-content: space-between;
    margin-bottom: 20px;
`;

Let’s now build the last component, where all the magic happens – the StepperSteps component.

import React, {useEffect} from 'react';
import {useStepper} from '../context';

export const StepperSteps = function({ children }) {
    const { currentStep, steps, setSteps } = useStepper();

    useEffect(() => {
        const stepperSteps = React.Children.toArray(children)
            .filter(step => {
                return step.type.name === 'StepperStep';
            })
            .map(step => step.props);
        setSteps(stepperSteps);
    }, [setSteps]);

    return (
        <div>
            {children &&
                React.Children.map(children, child => {
                    if (steps.length) {
                        return child.props.id === steps[currentStep].id
                            ? child
                            : null;
                    }
                })}
        </div>
    );
};

export const StepperStep = function({ children }) {
    return <>{children}</>;
};

Let me explain what is going on here. By using React.Children, we access each of the StepperSteps‘ children, iterate trough them and store their meta information(id and name props). If we reorder the steps in the markup, this will affect their order in the state as well. Below, you will find the definition of the StepperStep component, whose only purpose is to render its children. You might have noticed that, in the end result, I’ve used these components as Stepper.Steps and Stepper.Step. This is just for the sake of better readability. .

Now let’s put what we’ve built in practice.

Put all the pieces together

Here is the implementation of the the code:

import React from 'react-dom'
import Stepper from "./components/Stepper";
import styled from 'styled-components';
import {useStepper} from "./context";

function App() {
    const {incrementCurrentStep, decrementCurrentStep} = useStepper();

    return (
        <div className="container">
            <Stepper>
                <Stepper.Steps>
                    <Stepper.Step id="first" name="Step 1">
                        <StepBody>
                            <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</p>
                            <div>
                                <Button onClick={incrementCurrentStep}>Next step</Button>
                            </div>
                        </StepBody>
                    </Stepper.Step>
                    <Stepper.Step id="second" name="Step 2">
                        <StepBody>
                            <p>Ad alias debitis dolore, doloribus ducimus, eaque illum ipsum laboriosam libero
                                magnam.</p>
                            <div>
                                <Button onClick={decrementCurrentStep}>Previous step</Button>
                                <Button onClick={incrementCurrentStep}>Next step</Button>
                            </div>                        </StepBody>
                    </Stepper.Step>
                    <Stepper.Step id="third" name="Step 3">
                        <StepBody>
                            <p>Molestiae nihil nulla odio repellendus rerum similique suscipit unde veniam!</p>
                            <div>
                                <Button onClick={decrementCurrentStep}>Previous step</Button>
                                <Button onClick={incrementCurrentStep}>Next step</Button>
                            </div>                        </StepBody>
                    </Stepper.Step>
                    <Stepper.Step id="forth" name="Step 4">
                        <StepBody>
                            <p>Accusamus alias asperiores beatae dolores et expedita molestias nihil tempora?</p>
                            <div>
                                <Button onClick={decrementCurrentStep}>Previous step</Button>
                                <Button onClick={incrementCurrentStep}>Next step</Button>
                            </div>                        </StepBody>
                    </Stepper.Step>
                </Stepper.Steps>
            </Stepper></div>
    );
}

const StepBody = styled.div`
  text-align:center;
`

const Button = styled.button`
  margin:0 20px;
  cursor:pointer;
  outline:none;
  background:#fff;
  border:1px solid #000;
  padding:6px 12px;
`

export default App;

You see, the API is very simple, we just add the steps and the magic happens behind the curtains. You can easily extend the functionality or change the UI but the core functionality will remain. Here is the our Stepper in action: