Multistep Forms
INFO
React only for now — other framework bindings are planned.
useMultistep orchestrates a wizard's current-step state on top of one step per named FieldApi. It validates the current step, waits for it to settle, and gates the advance, so a hand-rolled multistep form doesn't repeat that per step.
import { useForm, useMultistep, Watch } from "@kin-form/react/index.ts";
type Checkout = {
shipping: { address: string; city: string };
payment: { cardNumber: string };
};
function CheckoutWizard() {
const form = useForm<Checkout>({
initialValue: {
shipping: { address: "", city: "" },
payment: { cardNumber: "" },
},
});
const {
stepName,
stepField,
isFirstStep,
isLastStep,
isTransitioning: isBusy,
back,
next,
} = useMultistep(form, ["shipping", "payment", null]);
return (
<div>
{stepName === "shipping" && <ShippingStep api={stepField} />}
{stepName === "payment" && <PaymentStep api={stepField} />}
{stepName === null && <ReviewStep form={form} />}
{!isFirstStep && <button onClick={back}>Back</button>}
{!isLastStep && <button onClick={next} disabled={isBusy}>Next</button>}
{isLastStep && <SubmitButton form={form}>Submit</SubmitButton>}
</div>
);
}Step names
Each entry in the second argument of useMultistep (stepNames) is the DeepKey of that step's own FieldApi, or null for a step with no FieldApi (e.g. a final review screen that only reads other steps' values). next() treats a null step as always valid, skipping straight to onBeforeNext.
next()
- If the current step has a
FieldApi, waits for its validation to settle. If invalid, marks ittouched(so errors on never-blurred fields become visible) and returnsfalsewithout advancing. - Calls
onBeforeNext, if given. - Advances to the next linear index — unless
onBeforeNextredirected elsewhere (see below).
Branching and persisting progress
onBeforeNext runs after the current step passes validation but before it advances — the hook for persisting progress (e.g. saving a draft) or branching to a non-linear next step:
const wizard = useMultistep(form, ["account", "shipping", "billing", null], {
onBeforeNext: async ({ form, stepName }) => {
await saveDraft(form.value);
if (stepName === "account" && form.value.shipping.sameAsBilling) {
return "billing"; // Skip the shipping step entirely.
}
// Returning nothing (or `true`) proceeds to the next linear index.
},
});Returning false (or throwing) cancels the advance, leaving stepIndex unchanged. Returning a step name (or null) redirects there instead.
back() and jump()
Neither validates — they're for navigation the user triggers directly (a step list, a "Skip" control), not for the current step's own "Next" action:
wizard.back(); // Previous step.
wizard.jump(0); // By index, clamped to range.
wizard.jump("payment"); // By step name.onStepChanged
Runs after stepIndex actually changes, from next(), back(), or jump() alike. Purely informational — it can't cancel anything:
useMultistep(form, steps, {
onStepChanged: ({ stepIndex }) => trackWizardStep(stepIndex),
});