Building a Multi-Step Checkout Form with React MUI Stepper and React Hook Form
Multi-step forms are a common pattern in modern web applications, especially for complex processes like checkout flows. They improve user experience by breaking down lengthy forms into manageable sections. In this article, I'll show you how to build a robust multi-step checkout form using Material UI's Stepper component integrated with React Hook Form for validation.
As a developer who has implemented countless checkout flows, I've found that combining MUI's visual clarity with React Hook Form's validation capabilities creates an optimal solution. Let's dive in and build something that's both functional and user-friendly.
Learning Objectives
By the end of this tutorial, you'll be able to:
- Implement a multi-step form using MUI's Stepper component
- Integrate React Hook Form for form validation and state management
- Create a cohesive checkout flow with shipping, billing, and confirmation steps
- Handle form data persistence between steps
- Implement proper form validation with visual feedback
- Style and customize the Stepper component to match your design requirements
Understanding MUI Stepper Component
The MUI Stepper is a navigation component that guides users through a process flow. It's particularly useful for multi-step forms, wizards, and checkout processes where users need to complete tasks in a sequential order.
Core Concepts and Variants
MUI's Stepper component comes in four main variants:
- Horizontal Stepper (default): Displays steps horizontally across the page
- Vertical Stepper: Arranges steps vertically, useful for mobile or when steps have detailed content
- Mobile Stepper: A compact version optimized for mobile interfaces
- Non-linear Stepper: Allows users to navigate freely between steps (not necessarily in sequence)
Each variant serves different UX needs. For checkout flows, the horizontal stepper is typically used on desktop, while vertical or mobile steppers work better on smaller screens.
Essential Props and Configuration
Prop | Type | Default | Description |
---|---|---|---|
activeStep | number | 0 | Sets the active step (zero-based index) |
alternativeLabel | boolean | false | Places the step label under the step icon |
children | node | - | Step elements to display (usually Step components) |
connector | element | StepConnector | Element that separates steps |
nonLinear | boolean | false | Allows clicking on any step (not sequential) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Sets the stepper orientation |
The Stepper component works in conjunction with several child components:
- Step: Represents an individual step in the sequence
- StepLabel: Contains the label and optional icon for a step
- StepContent: Contains the detailed content for a step (used mainly in vertical steppers)
- StepButton: Makes a step interactive in non-linear steppers
- StepConnector: The line connecting steps (customizable)
- StepIcon: The icon representing a step's state (customizable)
Customization Options
The Stepper component can be customized in several ways:
- Theming: Adjust colors, typography, and spacing through the MUI theme
- Styling: Use the
sx
prop for direct styling or create styled components - Custom Connectors: Replace the default line connector with custom elements
- Custom Icons: Replace the default circle icons with custom icons or components
Here's a quick example of customizing the Stepper's appearance:
// Custom stepper styling
<Stepper
activeStep={activeStep}
sx={{
'& .MuiStepIcon-root': {
color: 'grey.300',
'&.Mui-active': {
color: 'primary.main',
},
'&.Mui-completed': {
color: 'success.main',
},
},
'& .MuiStepConnector-line': {
borderColor: 'grey.300',
},
}}
>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
Accessibility Considerations
The Stepper component follows accessibility best practices, but there are some additional considerations:
- ARIA attributes: The component automatically adds appropriate ARIA roles
- Keyboard navigation: Ensure users can navigate between steps using keyboard
- Focus management: Maintain proper focus when moving between steps
- Error states: Clearly communicate validation errors with both visual and screen reader cues
Understanding React Hook Form
React Hook Form (RHF) is a performant, flexible form validation library that minimizes re-renders and provides a great developer experience. It's particularly well-suited for multi-step forms because it can manage complex form state efficiently.
Key Features for Multi-Step Forms
- Form State Management: RHF maintains a single source of truth for your form data
- Validation: Built-in validation with support for schema validation libraries
- Field Registration: Simple API for registering form inputs
- Error Handling: Comprehensive error state management
- Form Submission: Controlled form submission with data processing
Basic Setup and Integration
To use React Hook Form with MUI components, we need to bridge the gap between RHF's register
function and MUI's input components. Here's how to set up a basic form:
import { useForm, Controller } from 'react-hook-form';
import { TextField, Button } from '@mui/material';
function BasicForm() {
const { control, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="firstName"
control={control}
defaultValue=""
rules={{ required: 'First name is required' }}
render={({ field }) => (
<TextField
{...field}
label="First Name"
variant="outlined"
error={!!errors.firstName}
helperText={errors.firstName?.message}
fullWidth
margin="normal"
/>
)}
/>
<Button type="submit" variant="contained" color="primary">
Submit
</Button>
</form>
);
}
In a multi-step form, we'll extend this pattern to manage form state across multiple steps.
Building the Multi-Step Checkout Form
Now let's combine MUI Stepper with React Hook Form to create a complete checkout experience. We'll build a three-step checkout flow:
- Customer Information: Name, email, phone
- Shipping Address: Address details
- Payment Details: Credit card information and order summary
Project Setup
First, let's set up our project and install the necessary dependencies:
# Create a new React project
npx create-react-app checkout-form
# Navigate to project directory
cd checkout-form
# Install dependencies
npm install @mui/material @emotion/react @emotion/styled
npm install react-hook-form @hookform/resolvers yup
Creating the Base Structure
Let's start by creating the main component structure for our multi-step form:
// CheckoutForm.jsx
import React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
} from '@mui/material';
// Step components (we'll create these next)
import CustomerForm from './CustomerForm';
import ShippingForm from './ShippingForm';
import PaymentForm from './PaymentForm';
import Confirmation from './Confirmation';
const steps = ['Customer Information', 'Shipping Address', 'Payment Details'];
function CheckoutForm() {
const [activeStep, setActiveStep] = useState(0);
const methods = useForm({
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address1: '',
address2: '',
city: '',
state: '',
zipCode: '',
country: '',
cardName: '',
cardNumber: '',
expDate: '',
cvv: '',
},
});
const handleNext = async () => {
const isStepValid = await methods.trigger();
if (isStepValid) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleSubmit = (data) => {
console.log('Form submitted:', data);
// Here you would typically send the data to your server
setActiveStep(steps.length); // Move to confirmation step
};
const getStepContent = (step) => {
switch (step) {
case 0:
return <CustomerForm />;
case 1:
return <ShippingForm />;
case 2:
return <PaymentForm />;
default:
return 'Unknown step';
}
};
return (
<Paper elevation={3} sx={{ p: 4, maxWidth: 600, mx: 'auto', my: 4 }}>
<Typography component="h1" variant="h4" align="center" gutterBottom>
Checkout
</Typography>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit)}>
{activeStep === steps.length ? (
<Confirmation />
) : (
<>
{getStepContent(activeStep)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
Back
</Button>
{activeStep === steps.length - 1 ? (
<Button
type="submit"
variant="contained"
color="primary"
>
Place Order
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleNext}
>
Next
</Button>
)}
</Box>
</>
)}
</form>
</FormProvider>
</Paper>
);
}
export default CheckoutForm;
This component establishes the core structure of our multi-step form. Let's break down what's happening:
- We use
useState
to track the active step - We set up React Hook Form with
useForm
and provide default values for all fields - We create a Stepper component with steps defined by our
steps
array - We use
FormProvider
to make form methods available to all child components - We conditionally render different form components based on the active step
- We provide navigation buttons (Back/Next/Submit) with appropriate handlers
Creating Step Components
Now, let's create each of the step components that will contain our form fields. First, the customer information form:
// CustomerForm.jsx
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { TextField, Grid } from '@mui/material';
function CustomerForm() {
const { control, formState: { errors } } = useFormContext();
return (
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Controller
name="firstName"
control={control}
rules={{ required: 'First name is required' }}
render={({ field }) => (
<TextField
{...field}
label="First Name"
variant="outlined"
fullWidth
error={!!errors.firstName}
helperText={errors.firstName?.message}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="lastName"
control={control}
rules={{ required: 'Last name is required' }}
render={({ field }) => (
<TextField
{...field}
label="Last Name"
variant="outlined"
fullWidth
error={!!errors.lastName}
helperText={errors.lastName?.message}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Controller
name="email"
control={control}
rules={{
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
}}
render={({ field }) => (
<TextField
{...field}
label="Email Address"
variant="outlined"
fullWidth
error={!!errors.email}
helperText={errors.email?.message}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Controller
name="phone"
control={control}
rules={{
required: 'Phone number is required',
pattern: {
value: /^[0-9]{10}$/,
message: 'Phone number must be 10 digits'
}
}}
render={({ field }) => (
<TextField
{...field}
label="Phone Number"
variant="outlined"
fullWidth
error={!!errors.phone}
helperText={errors.phone?.message}
/>
)}
/>
</Grid>
</Grid>
);
}
export default CustomerForm;
Next, let's create the shipping address form:
// ShippingForm.jsx
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { TextField, Grid, MenuItem } from '@mui/material';
// Sample country list - in a real app, you'd likely fetch this from an API
const countries = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'UK', name: 'United Kingdom' },
// Add more countries as needed
];
function ShippingForm() {
const { control, formState: { errors } } = useFormContext();
return (
<Grid container spacing={3}>
<Grid item xs={12}>
<Controller
name="address1"
control={control}
rules={{ required: 'Address line 1 is required' }}
render={({ field }) => (
<TextField
{...field}
label="Address Line 1"
variant="outlined"
fullWidth
error={!!errors.address1}
helperText={errors.address1?.message}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Controller
name="address2"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Address Line 2 (optional)"
variant="outlined"
fullWidth
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="city"
control={control}
rules={{ required: 'City is required' }}
render={({ field }) => (
<TextField
{...field}
label="City"
variant="outlined"
fullWidth
error={!!errors.city}
helperText={errors.city?.message}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="state"
control={control}
rules={{ required: 'State/Province is required' }}
render={({ field }) => (
<TextField
{...field}
label="State/Province"
variant="outlined"
fullWidth
error={!!errors.state}
helperText={errors.state?.message}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="zipCode"
control={control}
rules={{
required: 'Zip/Postal code is required',
pattern: {
value: /^[0-9]{5}(-[0-9]{4})?$/,
message: 'Invalid zip code format'
}
}}
render={({ field }) => (
<TextField
{...field}
label="Zip/Postal Code"
variant="outlined"
fullWidth
error={!!errors.zipCode}
helperText={errors.zipCode?.message}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="country"
control={control}
rules={{ required: 'Country is required' }}
render={({ field }) => (
<TextField
{...field}
select
label="Country"
variant="outlined"
fullWidth
error={!!errors.country}
helperText={errors.country?.message}
>
{countries.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
))}
</TextField>
)}
/>
</Grid>
</Grid>
);
}
export default ShippingForm;
Now, let's create the payment form:
// PaymentForm.jsx
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { TextField, Grid, Typography, Divider } from '@mui/material';
function PaymentForm() {
const { control, formState: { errors } } = useFormContext();
return (
<>
<Typography variant="h6" gutterBottom>
Payment Method
</Typography>
<Grid container spacing={3}>
<Grid item xs={12}>
<Controller
name="cardName"
control={control}
rules={{ required: 'Name on card is required' }}
render={({ field }) => (
<TextField
{...field}
label="Name on Card"
variant="outlined"
fullWidth
error={!!errors.cardName}
helperText={errors.cardName?.message}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Controller
name="cardNumber"
control={control}
rules={{
required: 'Card number is required',
pattern: {
value: /^[0-9]{16}$/,
message: 'Card number must be 16 digits'
}
}}
render={({ field }) => (
<TextField
{...field}
label="Card Number"
variant="outlined"
fullWidth
error={!!errors.cardNumber}
helperText={errors.cardNumber?.message}
inputProps={{ maxLength: 16 }}
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="expDate"
control={control}
rules={{
required: 'Expiration date is required',
pattern: {
value: /^(0[1-9]|1[0-2])/([0-9]{2})$/,
message: 'Format: MM/YY'
}
}}
render={({ field }) => (
<TextField
{...field}
label="Expiry Date (MM/YY)"
variant="outlined"
fullWidth
error={!!errors.expDate}
helperText={errors.expDate?.message}
inputProps={{ maxLength: 5 }}
placeholder="MM/YY"
/>
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Controller
name="cvv"
control={control}
rules={{
required: 'CVV is required',
pattern: {
value: /^[0-9]{3,4}$/,
message: 'CVV must be 3 or 4 digits'
}
}}
render={({ field }) => (
<TextField
{...field}
label="CVV"
variant="outlined"
fullWidth
error={!!errors.cvv}
helperText={errors.cvv?.message}
inputProps={{ maxLength: 4 }}
/>
)}
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Order Summary
</Typography>
{/* Here you would typically map through cart items */}
<Typography variant="body1">
Total: $99.99
</Typography>
</>
);
}
export default PaymentForm;
Finally, let's create the confirmation component:
// Confirmation.jsx
import React from 'react';
import { Typography, Box, CheckCircleOutline } from '@mui/icons-material';
function Confirmation() {
return (
<Box sx={{ textAlign: 'center', my: 4 }}>
<CheckCircleOutline sx={{ fontSize: 60, color: 'success.main', mb: 2 }} />
<Typography variant="h5" gutterBottom>
Thank you for your order!
</Typography>
<Typography variant="body1">
Your order number is #2001539. We have emailed your order
confirmation, and will send you an update when your order has
shipped.
</Typography>
</Box>
);
}
export default Confirmation;
Adding Form Validation with Yup
To enhance our form validation, let's add Yup schema validation. First, create a validation schema file:
// validationSchema.js
import * as yup from 'yup';
// Customer information schema
export const customerSchema = yup.object().shape({
firstName: yup.string().required('First name is required'),
lastName: yup.string().required('Last name is required'),
email: yup
.string()
.email('Invalid email format')
.required('Email is required'),
phone: yup
.string()
.matches(/^[0-9]{10}$/, 'Phone number must be 10 digits')
.required('Phone number is required'),
});
// Shipping address schema
export const shippingSchema = yup.object().shape({
address1: yup.string().required('Address is required'),
address2: yup.string(),
city: yup.string().required('City is required'),
state: yup.string().required('State/Province is required'),
zipCode: yup
.string()
.matches(/^[0-9]{5}(-[0-9]{4})?$/, 'Invalid zip code format')
.required('Zip code is required'),
country: yup.string().required('Country is required'),
});
// Payment details schema
export const paymentSchema = yup.object().shape({
cardName: yup.string().required('Name on card is required'),
cardNumber: yup
.string()
.matches(/^[0-9]{16}$/, 'Card number must be 16 digits')
.required('Card number is required'),
expDate: yup
.string()
.matches(/^(0[1-9]|1[0-2])/([0-9]{2})$/, 'Format: MM/YY')
.required('Expiration date is required'),
cvv: yup
.string()
.matches(/^[0-9]{3,4}$/, 'CVV must be 3 or 4 digits')
.required('CVV is required'),
});
// Combined schema for the entire form
export const checkoutSchema = customerSchema.concat(shippingSchema).concat(paymentSchema);
Now, let's update our main component to use these schemas:
// CheckoutForm.jsx (updated)
import React, { useState, useEffect } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import {
Box,
Stepper,
Step,
StepLabel,
Button,
Typography,
Paper,
} from '@mui/material';
// Import form components
import CustomerForm from './CustomerForm';
import ShippingForm from './ShippingForm';
import PaymentForm from './PaymentForm';
import Confirmation from './Confirmation';
// Import validation schemas
import {
customerSchema,
shippingSchema,
paymentSchema,
checkoutSchema
} from './validationSchema';
const steps = ['Customer Information', 'Shipping Address', 'Payment Details'];
function CheckoutForm() {
const [activeStep, setActiveStep] = useState(0);
const [validationSchema, setValidationSchema] = useState(customerSchema);
// Create form methods with the appropriate validation schema
const methods = useForm({
mode: 'onChange',
resolver: yupResolver(validationSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address1: '',
address2: '',
city: '',
state: '',
zipCode: '',
country: '',
cardName: '',
cardNumber: '',
expDate: '',
cvv: '',
},
});
// Update validation schema when active step changes
useEffect(() => {
switch (activeStep) {
case 0:
setValidationSchema(customerSchema);
break;
case 1:
setValidationSchema(shippingSchema);
break;
case 2:
setValidationSchema(paymentSchema);
break;
default:
setValidationSchema(customerSchema);
}
}, [activeStep]);
// Get current form values to persist between steps
const formValues = methods.getValues();
const handleNext = async () => {
const isStepValid = await methods.trigger();
if (isStepValid) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleSubmit = (data) => {
console.log('Form submitted:', data);
// Process order here (API call, etc.)
setActiveStep(steps.length); // Move to confirmation
};
const getStepContent = (step) => {
switch (step) {
case 0:
return <CustomerForm />;
case 1:
return <ShippingForm />;
case 2:
return <PaymentForm />;
default:
return 'Unknown step';
}
};
return (
<Paper elevation={3} sx={{ p: 4, maxWidth: 600, mx: 'auto', my: 4 }}>
<Typography component="h1" variant="h4" align="center" gutterBottom>
Checkout
</Typography>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit)}>
{activeStep === steps.length ? (
<Confirmation />
) : (
<>
{getStepContent(activeStep)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
>
Back
</Button>
{activeStep === steps.length - 1 ? (
<Button
type="submit"
variant="contained"
color="primary"
>
Place Order
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleNext}
>
Next
</Button>
)}
</Box>
</>
)}
</form>
</FormProvider>
</Paper>
);
}
export default CheckoutForm;
The key update here is using the yupResolver
to validate each step with the appropriate schema. We switch the validation schema based on the active step to ensure we only validate the fields relevant to the current step.
Advanced Customization and Enhancements
Now that we have our basic multi-step form working, let's explore some advanced customizations and enhancements.
Custom Stepper Styling
Let's customize the Stepper component to make it more visually appealing:
// CustomStepper.jsx
import React from 'react';
import {
Stepper,
Step,
StepLabel,
StepConnector,
styled,
} from '@mui/material';
import {
Check as CheckIcon,
Person as PersonIcon,
LocalShipping as ShippingIcon,
Payment as PaymentIcon,
} from '@mui/icons-material';
// Custom connector line between steps
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`&.MuiStepConnector-alternativeLabel`]: {
top: 22,
},
[`&.MuiStepConnector-active`]: {
[`& .MuiStepConnector-line`]: {
backgroundImage:
'linear-gradient( 95deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
},
},
[`&.MuiStepConnector-completed`]: {
[`& .MuiStepConnector-line`]: {
backgroundImage:
'linear-gradient( 95deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
},
},
[`& .MuiStepConnector-line`]: {
height: 3,
border: 0,
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : '#eaeaf0',
borderRadius: 1,
},
}));
// Custom step icon styles
const ColorlibStepIconRoot = styled('div')(({ theme, ownerState }) => ({
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[700] : '#ccc',
zIndex: 1,
color: '#fff',
width: 50,
height: 50,
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
...(ownerState.active && {
backgroundImage:
'linear-gradient( 136deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)',
}),
...(ownerState.completed && {
backgroundImage:
'linear-gradient( 136deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
}),
}));
// Custom step icon component
function ColorlibStepIcon(props) {
const { active, completed, className, icon } = props;
const icons = {
1: <PersonIcon />,
2: <ShippingIcon />,
3: <PaymentIcon />,
};
return (
<ColorlibStepIconRoot ownerState={{ completed, active }} className={className}>
{completed ? <CheckIcon /> : icons[String(icon)]}
</ColorlibStepIconRoot>
);
}
// Custom Stepper component
function CustomStepper({ activeStep, steps }) {
return (
<Stepper
alternativeLabel
activeStep={activeStep}
connector={<ColorlibConnector />}
sx={{ mb: 4 }} >
{steps.map((label) => (
<Step key={label}>
<StepLabel StepIconComponent={ColorlibStepIcon}>{label}</StepLabel>
</Step>
))}
</Stepper>
);
}
export default CustomStepper;
Now update our main component to use this custom stepper:
// In CheckoutForm.jsx, replace the standard Stepper with:
import CustomStepper from './CustomStepper';
// Then in the render method:
<CustomStepper activeStep={activeStep} steps={steps} />
Form Data Persistence
To ensure users don't lose their data if they navigate away from the page, let's add form persistence using localStorage:
// CheckoutForm.jsx (with persistence)
import React, { useState, useEffect } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
// ... other imports
function CheckoutForm() {
const [activeStep, setActiveStep] = useState(0);
const [validationSchema, setValidationSchema] = useState(customerSchema);
// Load saved form data from localStorage if available
const savedFormData = localStorage.getItem('checkoutFormData');
const defaultValues = savedFormData
? JSON.parse(savedFormData)
: {
firstName: '',
lastName: '',
email: '',
phone: '',
address1: '',
address2: '',
city: '',
state: '',
zipCode: '',
country: '',
cardName: '',
cardNumber: '',
expDate: '',
cvv: '',
};
// Create form methods with the appropriate validation schema and default values
const methods = useForm({
mode: 'onChange',
resolver: yupResolver(validationSchema),
defaultValues,
});
// Save form data to localStorage whenever it changes
useEffect(() => {
const subscription = methods.watch((formData) => {
localStorage.setItem('checkoutFormData', JSON.stringify(formData));
});
return () => subscription.unsubscribe();
}, [methods]);
// Update validation schema when active step changes
useEffect(() => {
switch (activeStep) {
case 0:
setValidationSchema(customerSchema);
break;
case 1:
setValidationSchema(shippingSchema);
break;
case 2:
setValidationSchema(paymentSchema);
break;
default:
setValidationSchema(customerSchema);
}
}, [activeStep]);
const handleSubmit = (data) => {
console.log('Form submitted:', data);
// Process order here (API call, etc.)
// Clear saved form data after successful submission
localStorage.removeItem('checkoutFormData');
setActiveStep(steps.length); // Move to confirmation
};
// ... rest of the component stays the same
}
Adding Step Progress Indicators
Let's enhance our form with step progress indicators:
// StepProgress.jsx
import React from 'react';
import { Box, LinearProgress, Typography } from '@mui/material';
function StepProgress({ activeStep, totalSteps }) {
const progress = (activeStep / totalSteps) * 100;
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Step {activeStep + 1} of {totalSteps}
</Typography>
<Typography variant="body2" color="text.secondary">
{Math.round(progress)}% Complete
</Typography>
</Box>
<LinearProgress variant="determinate" value={progress} />
</Box>
);
}
export default StepProgress;
Add this to our main component:
// In CheckoutForm.jsx
import StepProgress from './StepProgress';
// Add below the stepper
<StepProgress activeStep={activeStep} totalSteps={steps.length} />
Implementing Step Transitions
Let's add smooth transitions between steps using MUI's Fade component:
// In CheckoutForm.jsx
import { Fade } from '@mui/material';
// Modify the form content section:
<Fade in={true} timeout={500}>
<Box>{getStepContent(activeStep)}</Box>
</Fade>
Form Error Summary
To improve the user experience, let's add an error summary that displays all validation errors in the current step:
// ErrorSummary.jsx
import React from 'react';
import { useFormState } from 'react-hook-form';
import { Alert, List, ListItem, ListItemText } from '@mui/material';
function ErrorSummary() {
const { errors } = useFormState();
// Count the number of errors
const errorCount = Object.keys(errors).length;
if (errorCount === 0) {
return null;
}
return (
<Alert severity="error" sx={{ mt: 2, mb: 2 }}>
<List dense disablePadding>
{Object.entries(errors).map(([field, error]) => (
<ListItem key={field} disablePadding>
<ListItemText primary={error.message} />
</ListItem>
))}
</List>
</Alert>
);
}
export default ErrorSummary;
Add this to our form:
// In CheckoutForm.jsx
import ErrorSummary from './ErrorSummary';
// Add below the form content and above the navigation buttons
<ErrorSummary />
Best Practices and Common Issues
Best Practices for Multi-Step Forms
-
Validate Each Step Individually: Only validate fields in the current step, not the entire form.
-
Show Progress Clearly: Always let users know where they are in the process and how many steps remain.
-
Allow Back Navigation Without Data Loss: Users should be able to go back to previous steps without losing their entered data.
-
Provide Clear Error Messages: When validation fails, show clear, specific error messages that help users correct their input.
-
Save Data Between Steps: Use form state management to persist data between steps, even if the user navigates away.
-
Keep Steps Focused: Each step should focus on a specific category of information to avoid overwhelming users.
-
Optimize for Mobile: Ensure your form works well on mobile devices, as many users complete checkout on smartphones.
-
Add Step Summaries: Consider adding a summary of the data entered in previous steps to help users confirm their information.
Common Issues and Solutions
-
Issue: Form validation triggers for all fields, not just the current step. Solution: Use step-specific validation schemas and only validate the active step.
-
Issue: Data is lost when navigating between steps. Solution: Use a form library like React Hook Form to maintain state across steps.
-
Issue: Users can't tell which steps have errors. Solution: Add visual indicators to the stepper for steps with validation errors.
-
Issue: Poor performance with large forms. Solution: Implement memoization and avoid unnecessary re-renders by using React.memo and useCallback.
-
Issue: Difficulty implementing "Save and Continue Later" functionality. Solution: Implement form persistence with localStorage or a backend API.
Here's how to implement step error indicators:
// CheckoutForm.jsx (with step error indicators)
function CheckoutForm() {
// ... existing code
// Track which steps have errors
const [stepErrors, setStepErrors] = useState([false, false, false]);
// Update step errors when form state changes
useEffect(() => {
const subscription = methods.formState.isValid;
const errors = methods.formState.errors;
// Check if current step has errors
const hasErrors = Object.keys(errors).length > 0;
// Update step errors array
setStepErrors(prev => {
const newStepErrors = [...prev];
newStepErrors[activeStep] = hasErrors;
return newStepErrors;
});
return () => subscription;
}, [methods.formState, activeStep]);
// ... existing code
return (
<Paper elevation={3} sx={{ p: 4, maxWidth: 600, mx: 'auto', my: 4 }}>
{/* ... existing code */}
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label, index) => (
<Step key={label}>
<StepLabel error={stepErrors[index]}>{label}</StepLabel>
</Step>
))}
</Stepper>
{/* ... rest of the component */}
</Paper>
);
}
Performance Optimizations
For larger forms, consider these performance optimizations:
// Memoizing form components
import React, { memo } from 'react';
const CustomerForm = memo(function CustomerForm() {
// Component code
});
// Using useCallback for event handlers
const handleNext = useCallback(async () => {
const isStepValid = await methods.trigger();
if (isStepValid) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
}, [methods]);
// Conditional rendering of step content
const getStepContent = useCallback((step) => {
switch (step) {
case 0:
return <CustomerForm />;
case 1:
return <ShippingForm />;
case 2:
return <PaymentForm />;
default:
return null;
}
}, []);
Accessibility Enhancements
Ensuring your multi-step form is accessible is crucial. Here are some accessibility enhancements:
// Accessibility enhancements for CheckoutForm.jsx
// Add proper ARIA labels to the stepper
<Stepper
activeStep={activeStep}
sx={{ mb: 4 }}
aria-label="Checkout process steps"
>
{steps.map((label, index) => (
<Step key={label} aria-current={activeStep === index ? "step" : undefined}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
// Add descriptive button text for screen readers
<Button
variant="contained"
color="primary"
onClick={handleNext}
aria-label={`Proceed to ${activeStep < steps.length - 1 ? steps[activeStep + 1] : 'review'}`}
>
Next
</Button>
// Add form error announcements for screen readers
{formHasErrors && (
{" "}
<div
role="alert"
aria-live="assertive"
className="visually-hidden" // CSS class that hides content visually but not from screen readers
>
Form has errors that need to be corrected before proceeding
</div>
)}
Final Implementation
Let's put everything together for our complete, enhanced multi-step checkout form:
// App.jsx - Main application component
import React from 'react';
import { ThemeProvider, createTheme, CssBaseline, Container } from '@mui/material';
import CheckoutForm from './CheckoutForm';
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Container>
<CheckoutForm />
</Container>
</ThemeProvider>
);
}
export default App;
// CheckoutForm.jsx - Complete implementation
import React, { useState, useEffect, useCallback, memo } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import {
Box,
Button,
Typography,
Paper,
Fade,
Stepper,
Step,
StepLabel,
StepConnector,
styled,
} from '@mui/material';
import {
Check as CheckIcon,
Person as PersonIcon,
LocalShipping as ShippingIcon,
Payment as PaymentIcon,
} from '@mui/icons-material';
// Import form components
import CustomerForm from './CustomerForm';
import ShippingForm from './ShippingForm';
import PaymentForm from './PaymentForm';
import Confirmation from './Confirmation';
import ErrorSummary from './ErrorSummary';
import StepProgress from './StepProgress';
// Import validation schemas
import {
customerSchema,
shippingSchema,
paymentSchema
} from './validationSchema';
// Custom connector line between steps
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`&.MuiStepConnector-alternativeLabel`]: {
top: 22,
},
[`&.MuiStepConnector-active`]: {
[`& .MuiStepConnector-line`]: {
backgroundImage:
'linear-gradient( 95deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
},
},
[`&.MuiStepConnector-completed`]: {
[`& .MuiStepConnector-line`]: {
backgroundImage:
'linear-gradient( 95deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
},
},
[`& .MuiStepConnector-line`]: {
height: 3,
border: 0,
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : '#eaeaf0',
borderRadius: 1,
},
}));
// Custom step icon styles
const ColorlibStepIconRoot = styled('div')(({ theme, ownerState }) => ({
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[700] : '#ccc',
zIndex: 1,
color: '#fff',
width: 50,
height: 50,
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
...(ownerState.active && {
backgroundImage:
'linear-gradient( 136deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)',
}),
...(ownerState.completed && {
backgroundImage:
'linear-gradient( 136deg, #2196f3 0%, #1976d2 50%, #0d47a1 100%)',
}),
...(ownerState.error && {
backgroundColor: theme.palette.error.main,
}),
}));
// Custom step icon component
function ColorlibStepIcon(props) {
const { active, completed, className, icon, error } = props;
const icons = {
1: <PersonIcon />,
2: <ShippingIcon />,
3: <PaymentIcon />,
};
return (
<ColorlibStepIconRoot
ownerState={{ completed, active, error }}
className={className} >
{completed ? <CheckIcon /> : icons[String(icon)]}
</ColorlibStepIconRoot>
);
}
// Steps for our checkout process
const steps = ['Customer Information', 'Shipping Address', 'Payment Details'];
function CheckoutForm() {
const [activeStep, setActiveStep] = useState(0);
const [validationSchema, setValidationSchema] = useState(customerSchema);
const [stepErrors, setStepErrors] = useState([false, false, false]);
// Load saved form data from localStorage if available
const savedFormData = localStorage.getItem('checkoutFormData');
const defaultValues = savedFormData
? JSON.parse(savedFormData)
: {
firstName: '',
lastName: '',
email: '',
phone: '',
address1: '',
address2: '',
city: '',
state: '',
zipCode: '',
country: '',
cardName: '',
cardNumber: '',
expDate: '',
cvv: '',
};
// Create form methods with the appropriate validation schema and default values
const methods = useForm({
mode: 'onChange',
resolver: yupResolver(validationSchema),
defaultValues,
});
// Save form data to localStorage whenever it changes
useEffect(() => {
const subscription = methods.watch((formData) => {
localStorage.setItem('checkoutFormData', JSON.stringify(formData));
});
return () => subscription.unsubscribe();
}, [methods]);
// Update validation schema when active step changes
useEffect(() => {
switch (activeStep) {
case 0:
setValidationSchema(customerSchema);
break;
case 1:
setValidationSchema(shippingSchema);
break;
case 2:
setValidationSchema(paymentSchema);
break;
default:
setValidationSchema(customerSchema);
}
}, [activeStep]);
// Update step errors when form state changes
useEffect(() => {
const errors = methods.formState.errors;
const errorFields = Object.keys(errors);
// Determine which step each error belongs to
const customerFields = ['firstName', 'lastName', 'email', 'phone'];
const shippingFields = ['address1', 'address2', 'city', 'state', 'zipCode', 'country'];
const paymentFields = ['cardName', 'cardNumber', 'expDate', 'cvv'];
const stepErrorsArray = [
errorFields.some(field => customerFields.includes(field)),
errorFields.some(field => shippingFields.includes(field)),
errorFields.some(field => paymentFields.includes(field))
];
setStepErrors(stepErrorsArray);
}, [methods.formState.errors]);
const handleNext = useCallback(async () => {
const isStepValid = await methods.trigger();
if (isStepValid) {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
}, [methods]);
const handleBack = useCallback(() => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}, []);
const handleSubmit = useCallback((data) => {
console.log('Form submitted:', data);
// Process order here (API call, etc.)
// Clear saved form data after successful submission
localStorage.removeItem('checkoutFormData');
setActiveStep(steps.length); // Move to confirmation
}, []);
const getStepContent = useCallback((step) => {
switch (step) {
case 0:
return <CustomerForm />;
case 1:
return <ShippingForm />;
case 2:
return <PaymentForm />;
default:
return 'Unknown step';
}
}, []);
return (
<Paper
elevation={3}
sx={{ p: { xs: 2, sm: 4 }, maxWidth: 800, mx: 'auto', my: 4 }}
aria-label="Checkout form" >
<Typography component="h1" variant="h4" align="center" gutterBottom>
Checkout
</Typography>
<Stepper
activeStep={activeStep}
connector={<ColorlibConnector />}
sx={{ mb: 4 }}
alternativeLabel
aria-label="Checkout process steps"
>
{steps.map((label, index) => (
<Step
key={label}
aria-current={activeStep === index ? "step" : undefined}
>
<StepLabel
StepIconComponent={ColorlibStepIcon}
StepIconProps={{ error: stepErrors[index] }}
error={stepErrors[index]}
>
{label}
</StepLabel>
</Step>
))}
</Stepper>
{activeStep < steps.length && (
<StepProgress activeStep={activeStep} totalSteps={steps.length} />
)}
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit)}>
{activeStep === steps.length ? (
<Confirmation />
) : (
<>
<Fade in={true} timeout={500}>
<Box>
{getStepContent(activeStep)}
</Box>
</Fade>
<ErrorSummary />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
variant="outlined"
aria-label={
activeStep > 0
? `Go back to ${steps[activeStep - 1]}`
: undefined
}
>
Back
</Button>
{activeStep === steps.length - 1 ? (
<Button
type="submit"
variant="contained"
color="primary"
aria-label="Complete checkout and place order"
>
Place Order
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleNext}
aria-label={
activeStep < steps.length - 1
? `Proceed to ${steps[activeStep + 1]}`
: undefined
}
>
Next
</Button>
)}
</Box>
</>
)}
</form>
</FormProvider>
</Paper>
);
}
export default CheckoutForm;
Wrapping Up
In this comprehensive guide, we've built a robust multi-step checkout form using MUI's Stepper component integrated with React Hook Form. We've covered everything from basic implementation to advanced customizations, accessibility enhancements, and performance optimizations.
The combination of MUI's visual components with React Hook Form's validation capabilities creates a powerful, user-friendly checkout experience. By breaking down the form into manageable steps, providing clear validation feedback, and maintaining form state between steps, we've created a solution that guides users smoothly through the checkout process.
To further enhance this implementation, consider adding features like address validation APIs, saved payment methods, and responsive design optimizations for different screen sizes. With the foundation we've built, you can easily extend this solution to meet your specific requirements.