@k8ordo/form

Multi-step forms

A long form is easier to fill in when split into steps. Since @k8ordo/form keeps the values in the DOM, nothing has to carry them from step to step: render every step, and hide the ones not in view.

On this page

Keep every step rendered, and hide the rest

Wrap each step in a <fieldset> and mark every step but the current one hidden. The hidden steps’ fields stay in the DOM, so moving between steps loses nothing and the form is sent once, at the end.

entry-form.tsx
const subscribe = () => () => {};
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);

<fieldset hidden={hydrated && step !== 0} ref={stepOne}>
  {/* step 1 */}
</fieldset>
<fieldset hidden={hydrated && step !== 1}>
  {/* step 2 */}
</fieldset>

Hide steps only once hydrated. Hidden in the server’s HTML, the later steps would be out of reach without JavaScript.

Left visible, the form degrades to one long page that submits in a single request without JavaScript. That is the correct behaviour, not a broken one.

Check the current step before moving on

Before moving on, check only the controls inside the current step with checkValidity(). The later steps are not filled in yet, so checking the whole form would always fail.

entry-form.tsx
const next = () => {
  const controls = stepOne.current?.querySelectorAll('input') ?? [];
  const invalid = [...controls].find((control) => !control.checkValidity());
  if (invalid !== undefined) {
    invalid.focus();
    return;
  }
  setStep(1);
};

useForm shows an error when the person leaves a field. Move focus to the first failed field, and its error appears once they leave it.

Render the submit button on the last step only

Once hydrated, render the submit button on the last step only.

entry-form.tsx
{(!hydrated || step === 1) && <button type="submit">Submit</button>}
Warning

Pitfall

Enter in a text field clicks the form’s first submit button, even when it sits in a hidden step. The whole form would be sent from an earlier step, skipping the per-step check.

Return to the step holding the error

After a failed submission useForm moves focus to the first failed field on the page, but a field inside a hidden step cannot take focus.

When a new state arrives, switch during render to the earliest step holding a key of state.errors. The field is visible by the time focus moves, so focus reaches it.

entry-form.tsx
const STEP_OF: Record<string, number> = {
  name: 0,
  email: 0,
  title: 1,
  minutes: 1,
};

const [shownState, setShownState] = useState(state);
if (state !== shownState) {
  setShownState(state);
  const failed = Object.keys(state.errors ?? {}).map(
    (key) => STEP_OF[key] ?? 0,
  );
  if (failed.length > 0) setStep(Math.min(...failed));
}

The browser-side check on submit covers the hidden steps too, and stops the submission for a failure there without being able to show it. Checking each step before moving on is what keeps the earlier steps from failing at the end.

Playground

Try a two-step form

The first step asks for contact details, the second for the talk. Both steps live in the same <form>.

Step 1 of 2

Contact
The talk

Try it

  1. Press “Next” with the name empty. The form stays on the step and focus moves to the name.
  2. Fill the first step, move on, and press “Back”. What you typed is still there.
  3. Type 90 as the length on the second step and press “Submit”. The submission stops and the error shows.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2