17
Bettered stepper handling in React
Often in a React project, we need to do some kind of stepper with rendering components one after another or so. Let's take a look at the simple example.
function SomeModal() {
[isFirstStep, setIsFirstStep] = React.useState(true);
return (
<div>{isFirstStep ? <FirstStepComponent /> : <SecondStepComponent />}</div>
);
}
This is a trivial example to just get the point, and I think you solving such a task all the time. This binary checking works well if we have 2 steps to show. The funny things happening when we need more than 2 steps. Often some times to handle the stepper state, we solve using some kind of object with the active steps saved in, and then conditional rendering the current step. The problem is that we need to pass the handle function to all the components we need to manipulate our steps state. And sometimes it can look very messy.
Let's build a custom hook and wrapped it to the context to abstract all the manipulation and make our code reusable and clean.
live example of the final solution
https://codesandbox.io/s/zealous-moore-9yrbn?file=/src
First of all, let's build a custom hook that will control our stepper
use-stepper.tsx
import * as React from 'react';
type StepId = string;
export type Step = {
id: StepId;
order: number;
};
type UseStepperProps = {
steps: Step[];
initialStep: StepId;
};
function byStepId(stepId: StepId) {
return (step: Step) => {
return step.id === stepId;
};
}
function sortByOrder(stepOne: Step, stepTwo: Step) {
return stepOne.order - stepTwo.order;
}
function getId(step: Step) {
return step.id;
}
export function useStepper(props: UseStepperProps) {
const indexes = React.useMemo(
() => props.steps.sort(sortByOrder).map(getId),
[props.steps],
);
const [currentStep, setCurrentStep] = React.useState(() =>
props.steps.find(byStepId(props.initialStep)),
);
function nextStep() {
const nextIndex = indexes.indexOf(currentStep.id) + 1;
if (nextIndex >= indexes.length) {
return;
}
const nextStep = props.steps[nextIndex];
setCurrentStep(nextStep);
}
function goToStep(stepId: StepId) {
const step = props.steps.find(byStepId(stepId));
if (process.env.NODE_ENV !== 'production') {
if (!step) {
throw new Error(`Step Id "${stepId}" is not
registered`);
}
}
if (step) {
setCurrentStep(step);
}
}
function prevStep() {
const prevIndex = indexes.indexOf(currentStep.id) - 1;
if (prevIndex < 0) {
return;
}
const prevStep = props.steps[prevIndex];
setCurrentStep(prevStep);
}
function isCurrentStep(stepId: StepId) {
return stepId === currentStep.id;
}
return {
currentStep,
nextStep,
prevStep,
goToStep,
isCurrentStep,
};
}
What going on here? We will describe steps as an object with the strings of id and order of a current showing step (will show this below) and use prevStep, goToStep, currentStep.. functions to manipulate the step we render.
Ok let's move on to create your step context, we wrap our steps components in and use the hook.
stepper-context.tsx
import * as React from 'react';
import { useStepper } from '..';
export const StepperContext = React.createContext<ReturnType<typeof useStepper>>(
undefined,
);
export function useStepperContext() {
const value = React.useContext(StepperContext);
if (value === undefined) {
throw new Error('Stepper Context is undefined');
}
return value;
}
We create a context for passing our values from useStepper and useStepperContext to use them in future components.
One more thing, we need to develop stepper.tsx component, it will wrapped up our components and will manage rendering under the hood.
stepper.tsx
import * as React from 'react';
import { StepperContext, useStepperContext } from '..';
import { useStepper } from '..';
type StepId = string
type StepType = {
id: StepId;
order: number;
};
type StepperProps = React.PropsWithChildren<{
steps: StepType[];
initialStep: StepId;
}>;
export function Stepper(props: StepperProps) {
const value = useStepper(props);
return (
<StepperContext.Provider value={value}>
{props.children}
</StepperContext.Provider>
);
}
type StepperStepProps = {
step: StepId;
component: React.ComponentType<any>;
};
export function Step(props: StepProps) {
const stepperContext = useStepperContext();
return stepperContext.isCurrentStep(props.step) ? <props.component /> : null;
}
It's done, now we can use this to run our steps like this just past our custom components inside the custom components, and use a hook for manage components rendering:
import * as React from "react";
import { Stepper, Step } from "..";
import { useStepperContext } from "..";
const STEPS = [
{ id: "first-step", order: 1 },
{ id: "second-components-step", order: 2 },
{ id: "id-for-the-third-step", order: 3 }
];
const FirstStep = () => {
const stepperContext = useStepperContext();
return (
<div>
<p>First step </p>
<button onClick={stepperContext.nextStep}>Next</button>
</div>
);
};
const SecondStep = () => {
const stepperContext = useStepperContext();
return (
<div>
<p>Some second step</p>
<button onClick={stepperContext.prevStep}>Prev</button>
<button onClick={stepperContext.nextStep}>Next</button>
</div>
);
};
const ThirdStep = () => {
const stepperContext = useStepperContext();
return (
<div>
<p>Third step</p>
<button onClick={stepperContext.prevStep}>Prev</button>
</div>
);
};
export function ContainerWithSteps() {
return (
<Stepper steps={STEPS} initialStep="first-step">
<Step step="first-step" component={FirstStep} />
<Step step="second-components-step" component={SecondStep} />
<Step step="id-for-the-third-step" component={ThirdStep} />
</Stepper>
);
}
You can check the live example here
https://codesandbox.io/s/zealous-moore-9yrbn?file=/src
17