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.

One definition, three views, one submit check A single whole-form schema sits at the centre. A per-step field mask derives three views from it: contact, covering email and phone; delivery, covering method, address line and postcode; and payment, covering the card number. Each view reuses the original field schemas by reference, so a message or rule is written once and cannot drift between a step view and the whole-form schema. The whole schema is still what the final submit validates, and what the server imports, so nothing about the split weakens the guarantee at the end. checkoutSchema every field, every rule written exactly once pick: contact email, phone pick: delivery method, line1, postcode pick: payment cardNumber NEXT guard only this step's keys only this step's errors Submit still uses the whole schema nothing is weakened

Step-by-Step Walkthrough

  1. Declare the field map, not three schemas. STEP_FIELDS is 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.

  2. Derive the view at the guard. validateStep runs on NEXT and nowhere else. Nothing calls the whole-form schema until submit.

  3. 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.

  4. 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.

  5. 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.

Three scopes, three moments, one definition A single field's schema runs on blur and reports one message beside that field. One step's picked schema runs on the next transition and reports messages for that step's fields only. The whole schema, including any cross-field refinements, runs at submit and is the check that actually guarantees correctness. Because all three are derived from the same object schema, a field cannot pass one scope and fail another for the same reason — which is what makes the narrowing safe rather than merely convenient. one field runs on: blur reports: one message shape[field] one step runs on: NEXT reports: this step only pick(STEP_FIELDS[id]) the whole form runs on: SUBMIT reports: everything checkoutSchema All three derive from one object schema so a field cannot pass on blur and fail on NEXT for the same reason — the narrowing is safe, not just convenient. Only the third scope is a guarantee. The first two exist to make the reader's path to it shorter.

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:

What a per-step view cannot judge A rule spanning two steps cannot live in either picked view and belongs on the whole-form schema at submit. A rule about a field owned by no step, such as a hidden token, also belongs there. A remote check belongs in the validation queue rather than in any schema the step guard runs. And a rule about the path itself, such as at least one delivery option being reachable, belongs in the machine rather than in a schema at all. The rule Cannot live in Belongs in spans two steps either step view the whole-form schema, at submit field owned by no step any step view the whole-form schema, at submit a remote check a synchronous schema the validation queue about the path itself a schema of any kind the wizard machine A rule you cannot place is usually a rule about the sequence rather than about any field in it.

Verification Checklist


Related

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.