keyed_form
A form-state controller for immutable aggregates. Pure Dart, no Flutter dependency.
KeyedFormController<Root> owns the editable draft, the field-keyed
validation errors, and the touched/dirty/revealed bookkeeping that decides
when an error is shown. Reads, writes, validation lookups and dirty
checks all speak the FieldRef vocabulary from
keyed_form_core, re-exported here — so UI code
addresses a field the same way whether it is reading it, writing it, or
asking for its error.
Observability is ChangeNotifier from package:listen (the official
Flutter-team observable package), so the controller can live in a plain
Dart test, a CLI, or — via keyed_form_flutter —
a widget tree.
Usage
final form = KeyedFormController<InvoiceForm>(
initialValue: const InvoiceForm(),
mode: KeyedFormMode.onTouched,
resolver: InvoiceForm.validateData, // generated by keyed_form_gen
);
form.field(InvoiceFields.customerEmail).set('ada@example.com');
form.field(InvoiceFields.customerEmail).error; // null until touched / submitted
form.validate(); // whole-draft validation, reveals every error
await form.submit(
(value) => api.save(value), // runs only if valid; toggles `submitting`
onInvalid: (errorKeys) => print('rejected: $errorKeys'),
);
form.field(ref) returns a FieldHandle — a statically-typed per-field
facade (set / update / value / error / dirty / touch(), and
list() for a list field). It is the everyday way in and out of a field;
.set(value) rejects a wrongly-typed value at compile time.
A field whose validation is a server round-trip (checking an email isn't
already taken) toggles its own isValidating flag around the check:
await form.field(InvoiceFields.customerEmail).validateAsync(
() => api.checkEmailAvailable(form.field(InvoiceFields.customerEmail).value),
);
Reading the controller's getters (form.value, form.field(x).value,
form.isDirty) is non-reactive — the getValues of this family, for event
handlers. To watch a slice in a widget's build, use keyed_form_flutter's
context.watchField / context.watchForm / context.selectForm.
Pieces
| Piece | What it is |
|---|---|
KeyedFormController<Root> |
Owns the draft, errors, touched, revealed, submitted/submitting; field(ref), touch, validate/validateScopes, submit(onValid, {onInvalid}), seed/reset, setServerErrors/setServerErrorPaths |
FieldHandle<Root, V> |
What form.field(ref) returns — set/update, value/error/dirty/key, touch(), list() for a list field |
validateAsync(check, {timeout, onFailure}) |
A field's own async check (e.g. against a server) — see below |
markReadOnly() / unmarkReadOnly() / isReadOnly |
Freeze a field against writes without affecting validation — see below |
form.addRelation(source, select, onChange) |
Derive one field's value from another — see below |
KeyedFormMode |
When a field's error becomes visible (onChange/onBlur/onTouched/onSubmit/all) |
KeyedFormResolver<Root> / KeyedFormScopeOf |
Validation function (draft, scope) => FieldErrors<String>, and an optional written-field-to-subtree mapper |
KeyedFormList<Root, Item> |
By-id editor for one list field — append/insert/removeById/move/updateById, …; obtain one with form.field(ref).list() |
KeyedFormSnapshot<Root> |
Immutable, ==-comparable point-in-time copy of the controller's coarse state, for a host that wants a value rather than a listenable |
KeyedFormMode never hides an error a submit attempt surfaced, nor one
explicitly revealed — the mode only governs visibility before one of those.
validateAsync — isValidating is true while the check runs. A thrown
check, or one that exceeds timeout, sets isFailedValidation instead of
writing an error or propagating — a technical fault ("couldn't check the
value"), kept separate from errors ("the value is invalid") and cleared by
the next validateAsync call, not sticky.
Read-only — freezing a key also freezes, by FieldKey ancestor
coverage, everything nested under it. Pass force: true to set/update
to write through the freeze. Read-only is configuration: it survives
seed()/reset(), unlike touched/revealed/validating/failed.
addRelation — calls onChange with the selected slice of source
whenever it actually changes; registering it does not itself call
onChange. Returns a callback to unsubscribe — the controller does not
track or dispose relations for you.
Scoped validation
For a large aggregate, pass scopeOf so each write re-validates only the
subtree it falls under instead of the whole draft. keyed_form_gen wires this
up for you — InvoiceForm.validateData honours the scope and
InvoiceForm.scopeOf (which delegates to rowScopeOf) is a sensible default:
KeyedFormController<InvoiceForm>(
initialValue: const InvoiceForm(),
resolver: InvoiceForm.validateData,
scopeOf: InvoiceForm.scopeOf, // or your own: (key) => key.prefix(2)
);
validateScopes re-validates and force-reveals a set of subtrees at once —
the "validate every dirty row before saving" operation — and returns which
of them still fail. Cross-field .refine(...) rules that sit above the
written scope only re-run on a full validate() (or validateScopes covering
them), so keep calling validate() on submit.
Scope
Deliberately out of scope: widgets (that is
keyed_form_flutter), schema/validation DSL (that
is keyed_form_schema), and any serialization format for the
draft itself.
Libraries
- keyed_form
- A form-state controller for immutable aggregates — the react-hook-form of
the
keyed_formfamily.