Building Step 3: Review and Submit
The final step in the capstone is StepReview. No new fields — just a read-only summary of everything the user entered, an opportunity to jump back and fix things, and a submit button that knows when the form is ready.
Reading All Form Values
StepReview doesn't render any form.Field components. It just reads state. The cleanest way to do that across the whole form is form.Subscribe with a selector that returns state.values:
function StepReview({
form,
onEditStep,
}: {
form: ReturnType<typeof useForm>
onEditStep: (step: number) => void
}) {
return (
<form.Subscribe
selector={(state) => state.values}
children={(values) => (
<div>
<h2>Review Your Information</h2>
<section>
<h3>
Personal Info
<button type="button" onClick={() => onEditStep(0)}>Edit</button>
</h3>
<p>Name: {values.firstName} {values.lastName}</p>
<p>Email: {values.email}</p>
</section>
<section>
<h3>
Preferences
<button type="button" onClick={() => onEditStep(1)}>Edit</button>
</h3>
<p>Interests: {values.interests.filter(Boolean).join(', ') || 'None'}</p>
<p>Notifications: {values.notificationPreference}</p>
{values.notificationPreference === 'sms' && (
<p>Phone: {values.phone}</p>
)}
</section>
</div>
)}
/>
)
}
state.values is the full form values object — every field, every array item. The selector runs on every state change but only triggers a re-render when the values actually change, so this stays efficient.
Edit Buttons
Each section header has an Edit button that calls onEditStep with the index of the step to jump back to. In the parent AccountForm, that prop is wired directly to setCurrentStep:
<StepReview form={form} onEditStep={setCurrentStep} />
The user clicks "Edit" on the Personal Info section, onEditStep(0) fires, currentStep becomes 0, and StepPersonalInfo renders. All the values are still in the form — nothing is lost between steps because the form instance lives in the parent.
Submit Button with Loading State
The submit button lives outside the review summary. It needs canSubmit and isSubmitting from form state, so it gets its own form.Subscribe:
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
)}
/>
canSubmit is false if any field has a validation error — so if the user somehow skipped past a required field, the button stays disabled. isSubmitting goes true the moment onSubmit is called and reverts when it resolves or throws. The label update is the simplest possible loading feedback.
Handling Submission Errors
The onSubmit handler in useForm wraps the API call. If it throws, you catch it and surface the error:
onSubmit: async ({ value }) => {
try {
await createAccount(value)
} catch (error) {
alert('Something went wrong. Please try again.')
}
}
For a real app you'd want to display the error inline rather than using alert, but the pattern is the same — catch the rejection and update some error state that renders in the UI.
Success State
After a successful submission, onSubmit resolves without throwing. At that point you can redirect or swap in a success message. A simple approach is a piece of state in the parent:
const [submitted, setSubmitted] = useState(false)
// in useForm
onSubmit: async ({ value }) => {
await createAccount(value)
setSubmitted(true)
}
// in the render
if (submitted) {
return <p>Account created! Check your email to get started.</p>
}
The Complete StepReview Component
function StepReview({
form,
onEditStep,
}: {
form: ReturnType<typeof useForm>
onEditStep: (step: number) => void
}) {
return (
<div>
<form.Subscribe
selector={(state) => state.values}
children={(values) => (
<div>
<h2>Review Your Information</h2>
<section>
<h3>
Personal Info
<button type="button" onClick={() => onEditStep(0)}>Edit</button>
</h3>
<p>Name: {values.firstName} {values.lastName}</p>
<p>Email: {values.email}</p>
</section>
<section>
<h3>
Preferences
<button type="button" onClick={() => onEditStep(1)}>Edit</button>
</h3>
<p>Interests: {values.interests.filter(Boolean).join(', ') || 'None'}</p>
<p>Notifications: {values.notificationPreference}</p>
{values.notificationPreference === 'sms' && (
<p>Phone: {values.phone}</p>
)}
</section>
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
)}
/>
</div>
)
}
In AccountForm, render it when currentStep === 2:
{currentStep === 2 && (
<StepReview form={form} onEditStep={setCurrentStep} />
)}
That's the full capstone. Three steps, one form instance, validation that stays out of the way until it matters, and a review screen that lets the user backtrack without losing their work.