@avinlab/form
v0.5.0
Published
Stable form controller with readonly snapshots and selective subscriptions.
Readme
@avinlab/form
A form is one stable mutable controller for its lifetime. values and prevValues are replaceable
readonly snapshots; calling a mutation method changes snapshots without replacing the controller.
Read snapshots imperatively and mutate only through the controller.
Install
npm install @avinlab/formAdvanced controller contract
setValueand full-replacementsetValuescompare every top-level field withObject.is.NaNis therefore equal toNaN, while0and-0are different. Objects, arrays, dates, maps, and sets change only when their field reference changes.- A no-op preserves both snapshot references and sends no notification. After a successful commit,
valuesis the new snapshot andprevValuesis the snapshot immediately before that commit. - Changed-field listeners run once each before whole-form listeners. Changed fields follow the key order of the next snapshot; removed fields follow their previous key order.
- An update requested by a listener is queued until the current notification cycle finishes. Reentrant updates run FIFO and every callback receives the current and previous snapshots for its own commit.
- Every listener captured for a notification is attempted. One failure is rethrown after dispatch;
multiple failures are reported in one
AggregateError. A listener failure does not roll back the committed snapshot. subscribeandsubscribeFielduse set semantics and return idempotent cleanup functions. Adding or removing listeners during dispatch affects the next commit, not the captured current cycle.- Snapshots are shallow-frozen in development and readonly in TypeScript. Nested field values are
not cloned or deeply frozen: replace a nested value through
setValueinstead of mutating it.
Controller recipe
import { createForm } from '@avinlab/form';
const form = createForm({ name: 'Bob', age: 20 });
const unsubscribeAge = form.subscribeField('age', (age, previousAge) => {
console.log({ age, previousAge });
});
const unsubscribeForm = form.subscribe((values, previousValues) => {
console.log({ values, previousValues });
});
form.setValue('age', 21);
form.setValues({ name: 'Ada', age: 36 }); // full snapshot replacement
unsubscribeAge();
unsubscribeForm();Keeping form in a dependency list or passing it through application layers is safe: successful
commits retain the exact same controller object.
Synchronous validation
createFormValidation derives one readonly validation result from the form. A validator runs
synchronously when configured, after each successful form commit, when replaced with a different
validator, or when validate() is called. It receives the committed values and prevValues.
The returned object is copied, top-level undefined entries are omitted, and the caller's object is
never mutated. status is unvalidated before any validator completes, valid for an empty
normalized result, and invalid otherwise. Subscribers receive the complete { status, errors }
result. Complete results are compared by deep structural equality: independently created nested
objects and arrays with the same contents preserve the existing result reference and do not notify.
Error structures must be acyclic.
import { createForm, createFormValidation } from '@avinlab/form';
interface Values {
name: string;
age: number;
}
type Errors = Partial<Record<keyof Values, string>>;
const form = createForm<Values>({ name: 'Bob', age: 20 });
const validation = createFormValidation<Errors, Values>(form, (values) => ({
name: values.name ? undefined : 'Name is required',
age: values.age >= 18 ? undefined : 'Must be at least 18',
}));
const unsubscribe = validation.subscribe((result) => console.log(result));
form.setValue('age', 15); // validation completes before setValue returns
validation.setValidator((values) => ({
age: values.age >= 21 ? undefined : 'Must be at least 21',
})); // recalculates the current snapshot synchronously
unsubscribe();
validation.dispose(); // idempotent; releases the form subscription and listenersWithout a validator, result is { status: 'unvalidated', errors: {} }; both the result and its
empty errors object are readonly. Only result.status === 'valid' means validation completed
successfully. If a validator throws, the controller publishes that empty unvalidated result before
propagating the exception. Disposal is explicit and idempotent; a disposed validation controller
retains its final result and no longer reacts to form commits.
Strictly compiled controller and validation recipes live in
examples/react/src/examples.
Advanced validation lifecycle
Core validation subscribes to the supplied form until dispose() is called. setValidator
recalculates synchronously, validate() explicitly repeats the current validator, and deeply
equivalent complete results preserve their reference without notifying. A validator exception first
publishes the shared empty unvalidated result and then rethrows. Subscription cleanup and disposal
are idempotent; after disposal, validation retains its final result and ignores validation,
validator, subscription, and form-update requests.
