Form Hydration
Apply this skill when the user needs to pre-fill a form, re-hydrate it after data loads, or push server-side errors back onto controls. v4 separated two concerns that v3 conflated: the reset baseline (defaultValue) and the current values (values / fill).
defaultValue vs values — the core distinction
| Prop / method | Role | Reactivity | Used by reset() |
|---|---|---|---|
defaultValue | the reset baseline | re-hydrates only pristine (non-dirty) controls when its identity changes | yes — form.reset() restores controls to it |
values | the current values | re-hydrates all mounted controls when its identity changes, and seeds later-mounting ones | no |
- Use
defaultValuefor static initial data known at first render, where “reset” should return to that data. - Use
values(orform.fill()) for an edit form whose record arrives after mount, or whenever you want to overwrite the live form, not the reset target.
// Static initial data; reset() returns here.<Form defaultValue={{ country: "EG", newsletter: true }}>
// Edit form — `user` loads async; changing its identity re-hydrates the form.<Form values={user}>Reactive values — re-hydrate on identity change
When the values prop’s object identity changes, the engine calls form.fill(values, { dirty: false, validate: false }):
- Already-mounted controls update to the new values.
- The snapshot (
engine.hydrationValues) is updated so controls that mount later seed from it viaform.getInitialValue(name).
function EditUserForm({ userId }: { userId: string }) { const { data: user } = useQuery(["user", userId], () => api.getUser(userId));
// While loading, `user` is undefined; once it resolves, its new identity // re-hydrates every control (and pre-seeds any that mount after). return ( <Form values={user} onSubmit={({ values }) => api.updateUser(userId, values)}> <TextInput name="name" /> <TextInput name="email" type="email" /> <SubmitButton>Save</SubmitButton> </Form> );}Pass a new object each time you want to re-hydrate (e.g. {...user} or a fresh query result). Mutating the same object in place won’t trigger it — identity must change.
Imperative hydration: fill / setValues
From a ref (or useForm()), write values onto controls in bulk without touching the reset baseline:
form.fill(values, { dirty?: false, validate?: false });form.setValues(values, options); // alias of filldirty(defaultfalse) — whether filled controls are marked dirty. Keepfalsefor hydration so the form doesn’t look “changed”.validate(defaultfalse) — whether to validate after writing.
fill merges into the hydration snapshot, so later-mounting controls still pick up the value. Only keys present in values (resolved by dot-notation get) are written — missing controls are left alone.
const formRef = useRef<FormInterface>(null);
const loadDraft = async () => { const draft = await api.getDraft(); formRef.current?.fill(draft); // hydrate, stays pristine};
const importAndValidate = async () => { const data = await api.import(); formRef.current?.fill(data, { dirty: true, validate: true });};getInitialValue(name) — how controls seed themselves
Each control, on mount, resolves its starting value (when no value / defaultValue prop was passed on the control itself) via form.getInitialValue(name), which checks, in order:
- the reactive hydration snapshot (
hydrationValues, seeded from thevaluesprop and updated byfill), then - the reset baseline (
defaultValue).
Returns undefined when neither has the key. This is why a control that mounts after a fill() / reactive values update still shows the right value — it reads the latest snapshot.
setDefaultValue — moving the reset baseline
Changing the defaultValue prop’s identity (or calling form.setDefaultValue(next)) updates the reset target. It re-hydrates only pristine controls — dirty controls keep the user’s edits, and their initialValue is updated so a later reset() lands on the new baseline.
form.setDefaultValue({ country: "SA" }); // pristine controls jump; edited ones don'tServer errors: setErrors (HTTP 422)
After a failed submit, map a server validation response back onto controls in one call. Keys are dot-notation control names; values are the messages:
const onSubmit = async ({ form, values }) => { try { await api.signup(values); } catch (error: any) { if (error?.status === 422 && error.body?.errors) { form.setErrors(error.body.errors); // { "email": "Already registered", ... } return; } toast.error("Signup failed"); }};setErrors:
- forces each matching control invalid with the given message (same slot the rule system writes to, so the input’s existing
errorrendering shows it), - ignores names with no matching control,
- re-checks form validity and fires
invalidControlsif anything is now invalid (so auseSubmitButtonre-disables).
For a single field, form.control(name)?.setError(message) still works; setErrors is the bulk version.
Putting it together — async edit form
function EditProfile({ id }: { id: string }) { const formRef = useRef<FormInterface>(null);
useEffect(() => { api.getProfile(id).then((profile) => { formRef.current?.fill(profile); // hydrate when data arrives }); }, [id]);
const onSubmit = async ({ form, values }) => { try { await api.saveProfile(id, values); } catch (e: any) { if (e?.status === 422) form.setErrors(e.body.errors); } };
return ( <Form ref={formRef} onSubmit={onSubmit}> <TextInput name="firstName" required /> <TextInput name="lastName" required /> <TextInput name="bio" /> <SubmitButton>Save</SubmitButton> </Form> );}(You can equally drive this with <Form values={profile}> and skip the manual fill — pick whichever fits your data flow.)
Anti-patterns
- Using
defaultValuefor data that loads after mount, then expecting it to overwrite the form —defaultValueonly re-hydrates pristine controls on identity change and is the reset target. For live overwrite usevalues/fill. - Mutating the same
valuesobject and expecting re-hydration — identity must change; pass a new object. - Calling
fill(values, { dirty: true })for hydration — that marks the form dirty (a “Save” gated onisDirtywould enable immediately). Keepdirty: false(the default) for seeding. - Forgetting that
setErrorsskips unknown names — a server error keyed to a field with no control on the page silently disappears; surface those at the form level.