Writing code
Forms
@uniflowed/form is React Hook Form's capability list without React Hook
Form's Proxy. Uncontrolled inputs, narrow subscriptions, typed values,
accessible errors wired for you — and one render where the incumbent gets
zero, for a reason the page gives you rather than hides.
A form
"use client";
// @flow
import { useForm } from "@uniflowed/form";
export component SignUp() {
const { register, handleSubmit, errorProps, formState } = useForm({
defaultValues: { email: "" },
mode: "onBlur",
});
return (
<form onSubmit={handleSubmit((values) => save(values))}>
<label htmlFor="email">Email</label>
<input
id="email"
{...register("email", {
required: "An email address is required",
pattern: { value: /@/, message: "That is not an email address" },
})}
/>
{formState.errors.email != null && (
<p {...errorProps("email")}>{formState.errors.email.message}</p>
)}
<button type="submit">Join</button>
</form>
);
}
register(name, rules) returns the props an uncontrolled input needs: name,
a ref, the change and blur handlers, and — only once the field has failed —
aria-invalid="true" and an aria-describedby. errorProps(name) returns the
matching id and role="alert".
That pairing is the part with no equivalent in React Hook Form, and it exists
because the alternative is worse than nothing: an aria-describedby pointing at
an element that is not rendered makes a screen reader announce nothing. The
ids come from useId, so two copies of the same form on one page do not collide.
mode decides when a field is first judged — "onSubmit" (the default),
"onBlur", "onChange", "onTouched" or "all" — and reValidateMode
decides what happens after it has failed once, defaulting to "onChange" so a
field that is showing a message updates as it is fixed.
What useForm gives you
register, unregister, errorProps, handleSubmit, watch, getValues,
setValue, getFieldState, reset, setError, clearErrors, trigger,
setFocus, formState and control.
formState carries errors, isDirty, dirtyFields, touchedFields,
isSubmitting, isSubmitted, isSubmitSuccessful, isValidating, isValid,
submitCount, disabled, isLoading and defaultValues.
Errors are flat, keyed by the string you registered:
formState.errors["profile.city"]; // not formState.errors.profile.city
Flow has no mapped type over a path, so "the error for items.0.name" cannot be
spelled as a nested shape. A flat map is the version that is true. To ask about
one field with the checker's help, use getFieldState("profile", "city") —
which is the next section.
A field read at its own type
A dotted path is a string, and a string cannot be checked against the shape of your values: Flow has no template-literal types. A path given as segments can be, and is:
const { getValues, setValue, watch, getFieldState } = useForm({
defaultValues: { email: "", address: { city: "", zip: 0 }, items: [{ price: 0 }] },
});
getValues("address", "city"); // string
watch("items", 0, "price"); // number
setValue(["items", 0, "price"], 12); // and 12 has to be a number
getFieldState("address", "city"); // the path is checked
getValues("address.city"); // still works, still mixed
Nothing is annotated: TValues is inferred from defaultValues and every read
is inferred from it. getValues("address", "country") is an error naming
country, and setValue(["items", 0, "price"], "cheap") is an error naming the
value's type — which no read could have told you.
Four things bound the claim, and each is a fact rather than a preference:
- Four segments deep, which reaches
items.0.tags.0. A fifth ismixed. The recursive form of the type binds its first segment asunknownin this checker — filed as #300 with a reproduction — so the depth is written out and capped. - A first segment that is not a key falls back to the dotted form, because
the dotted form has to keep working.
getValues("addres")ismixedrather than an error at the call, and is caught where the value is used at a type. A second segment is caught at the call. watch(["a", "b"])is not a path. It means the two fieldsaandb, as it does in React Hook Form. That is why the readers take segments as arguments; andsetValue, whose value has to follow the path, takes an array instead, because three string arguments could not be told apart at run time.useWatchtakespath, not segments. A hook declaration has one signature, so its typed path is one option and its value is computed by a conditional type. The type is the same; a misspelt segment there ismixedrather than a named error.
const city = useWatch({ control, path: ["address", "city"] }); // string
tests/type-tests/field-paths.js holds every line of this to what the checker
actually prints — including the lines that must not be errors.
Values that arrive later
An edit form usually does not know its values when it first renders. Three
options cover it, and none of them needs a useEffect:
// The record is fetched. The form renders immediately — empty, not dirty,
// with formState.isLoading true — and takes the values when they arrive.
useForm({ defaultValues: () => fetchRecord(id) });
// The record is owned by something else — a query cache, an atom, a row
// selected in a list — and the form follows it.
useForm({ values: record, resetOptions: { keepDirtyValues: true } });
// The server rejected the submit and said which fields.
useForm({ errors: rejection });
values re-seeds the form when the object it is given changes, which is the
useEffect calling reset you would otherwise write, at the same point in the
commit and without the render with the old values in it. Compared by identity
first and content second, so an object literal written inline is not a loop.
A re-seed is a reset, so resetOptions says what survives one — and that
answers the three questions a re-seed raises:
| Default | With | |
|---|---|---|
| A field the user has edited | replaced | kept, by keepDirtyValues |
Errors and isValid | cleared | kept, by keepErrors / keepIsValid |
| A field the new values omit | gone — the tree is replaced, not merged | — |
keepDirtyValues also recomputes which kept fields are still dirty: a field the
incoming record agrees with is not unsaved work any more.
An asynchronous defaultValues is called once, from an effect on mount, and
lands carefully. If the user has typed by the time it resolves, their text
stays and the rest of the record lands around it. If a reset(values) or a
values re-seed has happened, it does not land at all — the same stale-answer
rule the resolver runs on, applied to values.
defaultValues is the whole TValues, not React Hook Form's DeepPartial of
it: Flow can write Partial<T> and not the recursive version without an any
in the middle, and the whole object is the honest requirement anyway, because it
is what reset() goes back to.
There is no isReady. React Hook Form has one because its form finishes setting
itself up after the first render; this store is built in a useState
initialiser, so its first snapshot is already the real one — an isReady here
would be !isLoading under another name, or a flag that flipped in an effect
and cost every form a render to learn something already true.
Rules
Seven validators — required, min, max, minLength, maxLength,
pattern, validate — three transforms — valueAsNumber, valueAsDate,
setValueAs — and deps, for a field whose validity depends on another.
register("age", { valueAsNumber: true, min: { value: 18, message: "18 or over" } });
register("confirm", {
deps: ["password"],
validate: (value, values) => value === values.password || "The passwords differ",
});
Three behaviours worth knowing, each of them a decision:
- A blank value skips every rule except
requiredandvalidate.minLength: 8on an empty password would report "must be at least 8 characters", which is true and useless. falseis not blank. An unchecked checkbox is a real answer to a yes/no question.- Only the first failure is reported per field. A form showing every failing rule has to choose one to display anyway; choosing here means the choice is written down.
A schema instead of rules
import { useForm, validatorResolver } from "@uniflowed/form";
import { email, minLength, object, pipe, string } from "@uniflowed/validator";
const account = object({
email: pipe(string(), email()),
profile: object({ city: pipe(string(), minLength(2)) }),
});
component Account() {
const { register, handleSubmit } = useForm({
defaultValues: { email: "", profile: { city: "" } },
resolver: validatorResolver(account),
});
return (
<form onSubmit={handleSubmit((values) => save(values))}>
<input {...register("email")} />
<input {...register("profile.city")} />
<button type="submit">Save</button>
</form>
);
}
The schema is a value, so it is built once at module scope; the useForm call
is a hook, so it is in the component. That split is not a style preference —
useForm calls useId, useState and useSyncExternalStore, and a useForm
at module scope is an invalid hook call rather than a form.
Schema issues become field errors at the path the field was registered at, and
handleSubmit's callback receives the schema's output type, not the form's
input — so a transform(Number) in the schema means the submit handler sees a
number where the input held a string.
A resolver replaces the inline rules rather than merging with them: with a
resolver, register's rules are not consulted for validation at all. They still
describe how to read the control — valueAsNumber, setValueAs — because
that is a different question and a schema cannot answer it.
A synchronous resolver stays synchronous. Awaiting a value that is not a promise
is a microtask a form in onChange mode would pay on every keystroke, so
isValidating only flips when something really is pending.
Subscribe where the value is rendered
This is the performance story, and it is a different one from React Hook Form's.
component Total(control: mixed) {
const value = useWatch({ control, name: "title" });
return <output>{String(value ?? "")}</output>;
}
component Status(control: mixed) {
const { isDirty } = useFormState({ control, name: "title" });
return <p>{isDirty ? "changed" : "untouched"}</p>;
}
Both hooks take the control from useForm and subscribe to one path, so the
component that renders a value is the only one that re-renders when it changes.
useFormState({ control, name }) also scopes isDirty and isValid to the
named fields — in a slice, "dirty" means one of these was changed — while
isSubmitting, isSubmitted, isSubmitSuccessful, isValidating and
submitCount stay the whole form's.
Measured, typing six characters into one field, counting each component separately rather than the tree as a whole:
| Component | Renders |
|---|---|
The useForm owner — register, formState | 2 |
Total, subscribed to title through useWatch | 7 |
useState and a controlled input, one component for the field | 7 |
The owner's two are the mount and the moment the form first becomes dirty; the
next five keystrokes cost it nothing. Total's seven are the mount and one
per keystroke, and they are not a defect: a component displaying a value that
changed has to render. The subscription is what decides who renders, not
how often — and that is the whole trade, because what wakes on the sixth
keystroke here is an <output>, while the controlled row's identical seven are
seven renders of everything that component holds.
Put the two together and the shape is the point: a form of thirty fields with
one useWatch beside one of them costs two renders of the form and seven of an
<output>, not seven of the form.
React Hook Form gets the owner's second render to zero with a Proxy that
records which formState keys a render read. That technique depends on a render
having happened — which is exactly what the React Compiler is allowed to skip —
so this package does not use it, and says so instead of quietly being slower.
Arrays of fields
const { fields, append, remove, move } = useFieldArray({ control, name: "items" });
fields.map((field, index) => (
<input key={field.id} {...register(`items.${index}.name`)} />
));
append, prepend, insert, remove, swap, move, update and replace,
with field.id as a stable key across every one of them. Errors, dirty and
touched flags are remapped with the rows, so removing the first row does not
leave its error on the second.
A row's values in fields are stale on purpose — they are the values as of
the last array operation, not the last keystroke. Read a live one with
useWatch({ control, name: "items.3.price" }). The snapshot is cached in the
store rather than in a useMemo, because the React Compiler will hold a memo
over a store read forever, and there is a regression test for exactly that.
Controlled components
For an input that owns its own value — a select, a date picker, a third-party
editor — Controller binds one:
<Controller
control={control}
name="size"
render={(bound) => (
<select value={String(bound.field.value)} onChange={bound.field.onChange}>
<option value="s">small</option>
<option value="m">medium</option>
</select>
)}
/>
bound.field.onChange takes the value, not an event, though an event still
works. useController is the same thing as a hook.
Asynchronous validation
An async validate or resolver is expected, and two things are handled for you.
A slower answer never overwrites a newer one: every pass takes a sequence number
and stamps the fields it is about to answer for, and a result whose field has
since been stamped by a newer pass is dropped. And a validate that rejects —
as opposed to returning a message — is re-raised outside the promise chain
rather than swallowed, because that is a bug in your code and a silently
unvalidated field is how it would stay hidden for the rest of the session.
Coming from React Hook Form
| React Hook Form | Here | Why |
|---|---|---|
formState behind a Proxy | A plain object, one render on the dirty transition | A Proxy records reads during render, and the React Compiler may skip the render |
errors.profile.city | errors["profile.city"] | Flow has no mapped type over a path |
FieldPath<T>, a union of dotted strings | Dotted strings are mixed; segments are typed, four deep | Flow has no template-literal types, so a dotted path cannot be checked — but a generic bounded by the keys it indexes can |
| Resolver and rules both run | The resolver replaces the rules for validation | Rules still say how to read a control |
useFormContext() returns null outside a provider | It throws | A field with no form renders a control wired to nothing, and looks entirely correct |
| No accessible-error helper | register plus errorProps | The attributes are only correct in pairs |
ref(null) on unmount | A React 19 ref that returns a cleanup | It is the only form that says which element left — a radio group is several nodes under one name |
Typed where it can be: defaultValues, getValues(), reset(),
handleSubmit's callback argument, and every per-field read and write given as
segments. Untyped where Flow cannot: the dotted path strings, and the values
behind them.
Not implemented
shouldUnregister, delayError, form-level persistence, criteriaMode: "all"
— one error per field is structural here — formState.isReady, for the reason
above, and devtools.
One limitation is worth reading before a server-rendered form surprises you:
register gives an input a ref, and a ref does not run on a server, so
defaultValues alone puts no value into server-rendered HTML. A page that must
show its values before hydration should put them in the markup —
<input defaultValue={record.email} {...register("email")} />
— and the store adopts whatever the control already shows for any field it has no value for.
Every snippet on this page was run before it was published — the short ones as
excerpts of a component, since a fence that repeated the useForm call, the
control and the imports around every three lines would be mostly repetition.
tests/library/form.test.js is where the behaviour above is pinned, including
the three render counts, in "counts the owner, the watcher and a controlled
input over the same six keystrokes" — uf test#library form.test.js.