Skip to content

Multi-Step Wizard

A Stepper-driven multi-step form where state lives in a single reducer so step transitions preserve inputs.

Stepper on the left, form sections on the right. State lives in a single reducer so step transitions preserve inputs.

import { useReducer } from 'react';
import {
Button,
Form,
FormControl,
FormField,
FormLabel,
Heading,
Input,
Stack,
Stepper,
StepperItem,
StepperLabel,
StepperTrigger,
} from '@arshad-shah/cynosure-react';
type State = {
step: number;
values: { name?: string; email?: string; team?: string };
};
type Action =
| { type: 'next' }
| { type: 'back' }
| { type: 'goto'; step: number }
| { type: 'set'; values: Partial<State['values']> };
function reducer(s: State, a: Action): State {
switch (a.type) {
case 'next': return { ...s, step: Math.min(s.step + 1, 2) };
case 'back': return { ...s, step: Math.max(s.step - 1, 0) };
case 'goto': return { ...s, step: a.step };
case 'set': return { ...s, values: { ...s.values, ...a.values } };
}
}
export function Wizard() {
const [state, dispatch] = useReducer(reducer, {
step: 0,
values: {},
});
const submit = async () => {
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(state.values),
});
};
return (
<Stack direction={{ base: 'column', md: 'row' }} gap="6">
<Stepper orientation="vertical" value={state.step}>
<StepperItem value={0}>
<StepperTrigger onClick={() => dispatch({ type: 'goto', step: 0 })}>
<StepperLabel>Your details</StepperLabel>
</StepperTrigger>
</StepperItem>
<StepperItem value={1}>
<StepperTrigger onClick={() => dispatch({ type: 'goto', step: 1 })}>
<StepperLabel>Team</StepperLabel>
</StepperTrigger>
</StepperItem>
<StepperItem value={2}>
<StepperTrigger onClick={() => dispatch({ type: 'goto', step: 2 })}>
<StepperLabel>Confirm</StepperLabel>
</StepperTrigger>
</StepperItem>
</Stepper>
<Form
onSubmit={(e) => {
e.preventDefault();
if (state.step < 2) {
dispatch({ type: 'next' });
} else {
submit();
}
}}
>
<Stack gap="4" flex="1">
{state.step === 0 && (
<>
<Heading level={2}>Your details</Heading>
<FormField name="name">
<FormLabel>Full name</FormLabel>
<FormControl>
<Input
defaultValue={state.values.name}
onBlur={(e) =>
dispatch({ type: 'set', values: { name: e.target.value } })
}
/>
</FormControl>
</FormField>
<FormField name="email">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
defaultValue={state.values.email}
onBlur={(e) =>
dispatch({ type: 'set', values: { email: e.target.value } })
}
/>
</FormControl>
</FormField>
</>
)}
{state.step === 1 && (
<>
<Heading level={2}>Team</Heading>
<FormField name="team">
<FormLabel>Team name</FormLabel>
<FormControl>
<Input
defaultValue={state.values.team}
onBlur={(e) =>
dispatch({ type: 'set', values: { team: e.target.value } })
}
/>
</FormControl>
</FormField>
</>
)}
{state.step === 2 && (
<>
<Heading level={2}>Confirm</Heading>
<pre>{JSON.stringify(state.values, null, 2)}</pre>
</>
)}
<Stack direction="row" gap="3" justify="space-between">
<Button
type="button"
variant="outline"
onClick={() => dispatch({ type: 'back' })}
disabled={state.step === 0}
>
Back
</Button>
<Button type="submit">
{state.step < 2 ? 'Continue' : 'Create account'}
</Button>
</Stack>
</Stack>
</Form>
</Stack>
);
}