For most of Inertia.js's life, submitting a form meant reaching for the useForm hook: you destructure data, setData, post, processing, and errors, wire each field's value/onChange to data/setData by hand, and call post(route) on submit. It works, and for a single-page form it's barely worth a second thought. It stops being barely-worth-a-second-thought once the form in question is a multi-step onboarding wizard with conditional fields, cross-step validation, and a "back" button that has to preserve state.
Inertia v3 added a declarative <Form> component that handles the request lifecycle — submission, processing state, error propagation, success/failure callbacks — as a component rather than a hook you have to wire manually per field. On paper it reads like syntactic sugar. In practice, migrating a multi-step wizard to it surfaced bugs I'd been treating as one-off edge cases, and they turned out to be the same root cause repeated four different ways.
The bug pattern: state synchronization, not business logic
The wizard in question — a multi-step onboarding flow — had a recurring class of bug reports that looked unrelated on the surface:
- Going back from step 3 to step 2 sometimes showed step 2's fields as empty, even though the user had filled them in.
- A validation error from the server on step 2 would occasionally still be displayed after the user fixed the field and moved to step 3, then came back.
- The "Continue" button would stay disabled after a field was corrected, requiring an unrelated re-render (switching browser tabs and back) to re-enable.
- Submitting step 4 sometimes sent stale data from an earlier step if the user had navigated back and forward quickly.
Four different bug reports, but the same underlying cause: with useForm, each step owned its own local invocation of the hook, and the wizard's step-navigation logic was responsible for manually shuttling data between them. Every hand-off between steps was a place where "the data the user sees" and "the data the wizard's state actually holds" could drift apart — a setData call that fired before a state update from the previous step had settled, or a step component that re-mounted and re-initialized its useForm state from a default rather than from the wizard's authoritative in-progress data.
What <Form> changes structurally
The declarative <Form> component doesn't fix state synchronization by itself — nothing does, automatically. What it does is remove the place where the bug could hide: instead of each step independently managing a useForm instance and the wizard manually reconciling data across steps, the wizard has one source of truth for in-progress data, and <Form> is used at the point of actual submission per step, initialized explicitly from that shared state rather than from its own internal default.
function WizardStep({ stepData, onStepComplete }: WizardStepProps) {
return (
<Form
action={route("onboarding.step", { step: 2 })}
method="post"
// Explicit initial values from the wizard's shared state,
// not an internal default the component invents on mount.
transform={(data) => ({ ...stepData, ...data })}
onSuccess={(page) => onStepComplete(page.props.stepData)}
>
{({ errors, processing }) => (
<>
<input name="companySize" defaultValue={stepData.companySize} />
{errors.companySize && <FieldError message={errors.companySize} />}
<button type="submit" disabled={processing}>
Continue
</button>
</>
)}
</Form>
);
}
The meaningful change here isn't the JSX shape — it's that processing and errors are scoped to the actual request lifecycle of this submission, provided by the component rather than manually tracked state that has to be reset at exactly the right moments (on step change, on successful submission, on validation failure) by wizard logic that has to remember to do so every time a new step is added.
The "Continue" button bug was a stale-closure bug
The disabled-button issue — where processing stayed true after a request had actually resolved — traced back to a stale closure: the useForm instance's processing flag was being read inside a callback captured at a render before the request settled, because the step component's re-render wasn't guaranteed to happen in the same tick as the hook's internal state update under React 18's automatic batching. <Form>'s render-prop pattern ({({ processing }) => ...}) sidesteps this specific failure mode because the processing state is read at render time from the component actually rendering the button, not from a hook instance whose update timing has to be reasoned about separately.
The lesson generalizes past Inertia
The specific fix is Inertia-flavored, but the underlying lesson isn't: a surprising number of "flaky UI bug" reports in form-heavy React code are state-synchronization bugs wearing a UI costume. Multiple independent pieces of state (one useForm per step) that have to be manually kept consistent by application code (the wizard's step-navigation logic) will drift, eventually, under exactly the input sequence nobody wrote a test for — quick back-and-forth navigation, a validation error corrected out of order, a network response arriving later than the UI expects. The fix is rarely "add a useEffect to resync state." It's collapsing the number of places that state can independently exist in the first place, which is what moving from four independent useForm instances to one shared wizard state plus per-step <Form> submissions actually did here.