The exact problem: a wizard runs its whole schema on every step transition, so advancing from step one renders “Card number is required” against a step the reader has not reached — and the submit button is disabled for reasons that are three screens away.
Context and Prerequisites
This builds directly on multi-step form state machines, where a step’s validate function is the guard on the NEXT transition. It also assumes a schema layer of the kind described in integrating Zod for schema validation — the technique below is about scoping a schema, not about which library defines it.
The instinct that causes the problem is reasonable: one schema per form is easier to keep consistent than one per step. The fix is not to abandon that, but to keep one schema and derive per-step views from it, so the definition stays single and the evaluation becomes narrow.
Core Pattern: One Schema, Per-Step Views
import { z } from 'zod';
// One definition for the whole form. This is what submit validates, what the
// server imports, and what the types are inferred from.
const checkoutSchema = z.object({
email: z.string().email('Enter an email address we can reach you at'),
phone: z.string().min(7, 'Enter a phone number including the area code'),
method: z.enum(['home', 'collect']),
line1: z.string().min(1, 'Enter the first line of the address'),
postcode: z.string().min(4, 'Enter a postcode'),
cardNumber: z.string().length(16, 'Enter the 16 digits on the front of the card'),
});
// Which keys each step owns. This is the only thing a step declares — the rules
// themselves stay in the schema above, so a message is written once.
const STEP_FIELDS = {
contact: ['email', 'phone'],
delivery: ['method', 'line1', 'postcode'],
payment: ['cardNumber'],
} as const satisfies Record<string, readonly (keyof typeof checkoutSchema.shape)[]>;
type StepId = keyof typeof STEP_FIELDS;
/**
* Build a schema covering only one step's keys. `.pick()` reuses the original
* field schemas by reference, so a rule change lands in both the step view and
* the whole-form schema with no chance of drift.
*/
function stepSchema(step: StepId) {
const mask = Object.fromEntries(STEP_FIELDS[step].map((k) => [k, true as const]));
return checkoutSchema.pick(mask as Record<string, true>);
}
/** Validate one step. Returns a field error map, empty when the step passes. */
export function validateStep(step: StepId, values: Record<string, unknown>): FieldErrorMap {
const result = stepSchema(step).safeParse(values);
if (result.success) return {};
return Object.fromEntries(
result.error.issues.map((i) => [String(i.path[0]), { message: i.message, code: i.code }]),
);
}
The pick call is doing the important work. Because it reuses the field schemas by reference rather than copying them, there is exactly one place where “a postcode is at least four characters” is written down. Splitting the schema into three independent schemas would give the same narrow evaluation and immediately create three places for that rule to diverge.
Step-by-Step Walkthrough
-
Declare the field map, not three schemas.
STEP_FIELDSis the only thing a step owns. Adding a field to a step is a one-line change that cannot forget to bring its rule along. -
Derive the view at the guard.
validateStepruns onNEXTand nowhere else. Nothing calls the whole-form schema until submit. -
Return a map, not a boolean. The machine only needs “did it pass”, but the step needs the messages, and computing them twice is how the reader sees a blocked transition with no visible reason.
-
Validate the whole schema once, at submit. This is the check that matters, and it catches anything the per-step views could not see — a rule spanning two steps, or a field that belongs to no step at all.
-
Keep cross-step rules out of the step views. A refinement reading two steps cannot live in either
pick. Attach it to the whole-form schema and evaluate it at submit, or model it as a dependency edge in the machine.
Failure Modes and Edge Cases
1. A conditional step’s fields are required unconditionally
If the delivery address is only required when method === 'home', a pick over line1 will demand it even for a collection order. The fix is structural rather than conditional — express the branch as a discriminated union so the field only exists in the branch that needs it:
// The address fields exist only in the 'home' variant, so a collection order
// cannot fail a rule about a field it does not have.
const deliverySchema = z.discriminatedUnion('method', [
z.object({ method: z.literal('home'), line1: z.string().min(1), postcode: z.string().min(4) }),
z.object({ method: z.literal('collect'), pickupPointId: z.string().min(1) }),
]);
2. pick over a refined schema silently drops the refinement
A .superRefine() attached to the whole object does not survive a pick, because the refinement is a property of the object schema rather than of any field. This is usually what you want — a cross-field rule should not run inside one step — but it is worth knowing rather than discovering. If a rule genuinely belongs to one step, attach it to that step’s derived schema explicitly.
3. The submit check finds errors no step could show
A field that belongs to no step, or a cross-step rule, can fail at submit with nowhere to render. Route those to the form-level error summary, and make the SUBMIT guard navigate to the step owning the first field-scoped error. An error the reader cannot reach is indistinguishable from a form that is simply broken.
4. Per-step validation and per-field validation disagree
The step guard runs picked rules; the field’s own on-blur validation usually runs the single field schema. They must come from the same definition or a field can pass on blur and fail on NEXT, which reads as the form changing its mind. Deriving both from checkoutSchema.shape[field] keeps them identical.
5. The reader jumps back and the later step is not re-validated
Per-step validation on NEXT means a step validated once and never again. When an earlier answer changes, the machine’s stale phase is what forces the re-check; without it, a step validated under old inputs stays complete forever.
One last thing the step view cannot see, and where each of those belongs instead:
Verification Checklist
Related
- Multi-Step Form State Machines — where the step guard lives
- Discriminated Unions for Conditional Schemas — expressing a conditional step structurally
- Form Validation Lifecycle — when each scope is allowed to speak
← Multi-Step Form State Machines
Frequently Asked Questions
Why not just write one schema per step?
Because a rule then exists once per step that mentions it, and rules that appear twice diverge. The moment a postcode rule is needed on both the delivery step and a billing step, the two copies start drifting — usually in the message text first, then in the rule. Deriving step views with pick keeps the definition single while making the evaluation narrow, which is the actual goal.
What happens to cross-field rules that span two steps?
They cannot live in either step’s view, and that is the right outcome. Attach them to the whole-form schema so they run at submit, and if the reader needs to know earlier, model the relationship as a dependency edge in the wizard machine so changing one step marks the other stale. A refinement smuggled into one step’s schema makes that step untestable on its own and fires at a moment the reader cannot act on.
Should a blocked NEXT move focus, or just render the errors?
Move focus, to the first invalid field on that step. The reader pressed a button expecting to move, so leaving focus on the button after refusing gives them no indication of what to do next, and a screen reader reader hears nothing at all. Announce the count in a live region as well when more than one field failed, so the reader knows the size of the problem before they start.
Does picking a subset of the schema hurt performance?
No, and it usually helps. Building the picked schema costs a small object allocation, which you can memoise per step if it bothers you, and running it parses a fraction of the fields the whole schema would. The saving is not the point though — the point is that the reader only sees errors for fields they can currently see.