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.tsxconst 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.tsxconst 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>}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.tsxconst 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.
Try a two-step form
The first step asks for contact details, the second for the talk. Both steps live in the same <form>.
Try it
- Press “Next” with the name empty. The form stays on the step and focus moves to the name.
- Fill the first step, move on, and press “Back”. What you typed is still there.
- Type
90as the length on the second step and press “Submit”. The submission stops and the error shows.