Skip to main content

React MUI Form Stepper

A form stepper typically has two main elements. The first is a static section displaying progress, accompanied by "back" and "next" buttons. The second is a dynamic part that adjusts its content based on each step. Importantly, the "next" button is designed to be clickable only when the data entered in the currently active step is valid. When clicked, it triggers a callback to handle the entered data, such as saving it.

The challenge arises because the button state and callback depend on the currently active form step component. However, the stepper and associated buttons are typically not direct children.

A solution involves utilizing useRef and createPortal. This allows the step component to render the button in a DOM node located outside its own DOM hierarchy. The <Portal/> component in Material UI conveniently encapsulates this functionality. Adopting this strategy enables smooth management and control of the "next" button directly within the form step component.

Important

Only use containerRef within a useEffect or function, given that it gets assigned after the initial render. Instead of directly using containerRef.current, use () => containerRef.current.

import React from "react";
import { Box, Button, MobileStepper, Portal } from "@mui/material";

type StepProps = {
next: () => void;
containerRef: React.MutableRefObject<any>;
};

function Step({ next, containerRef }: StepProps) {
const [isValid, setIsValid] = React.useState<boolean>(false);

const handleNext = () => {
// do something with form data
next();
};

const handleToggle = () => {
setIsValid((x) => !x);
};

return (
<React.Fragment>
<Box>
{
// form body
}
<Button size="small" variant="contained" onClick={handleToggle}>
Validate
</Button>
</Box>
<Portal container={() => containerRef.current}>
<Button size="small" onClick={handleNext} disabled={!isValid}>
Next
</Button>
</Portal>
</React.Fragment>
);
}

function Stepper() {
const containerRef = React.useRef(null);
const [activeStep, setActiveStep] = React.useState<number>(0);

const next = () => {
setActiveStep((x) => x + 1);
};

const handleBack = () => {
setActiveStep((x) => x - 1);
};

return (
<Box
sx={{
width: "400px",
}}
>
{activeStep === 0 && <Step next={next} containerRef={containerRef} />}
{activeStep === 1 && <Step next={next} containerRef={containerRef} />}
{activeStep === 2 && <Step next={next} containerRef={containerRef} />}

<MobileStepper
variant="dots"
steps={3}
position="static"
activeStep={activeStep}
nextButton={
<Box ref={containerRef}>
{
// portal container for "next" button
}
</Box>
}
backButton={
<Button size="small" onClick={handleBack} disabled={activeStep === 0}>
Back
</Button>
}
/>
</Box>
);
}