@dccs/teff-rfc
v1.1.0-alpha.1
Published
Reactive form engine for deep, controlled enterprise forms. A plain-JavaScript signal graph owns the state; React only paints. Deep type-safety, auto-wired cross-field validation, fine-grained performance.
Readme
@dccs/teff-rfc
A reactive form engine for deep, controlled enterprise forms. A plain-JavaScript signal graph owns the state; React only paints. The result: deep type-safety that never explodes, cross-field validation that wires itself, and fine-grained performance at hundreds of fields — behind a clean object-access API with no render props and no path strings.
Status: alpha. Published for early feedback. The core engine (state graph, validation, arrays, React bindings) is stable and fully tested, but a built-in submit lifecycle (
handleSubmit,isSubmitting,submitCount) does not exist yet and the API may still change before the first stable release. Install withnpm install @dccs/teff-rfc@alpha.
f.company.departments.at(0)!.field.lines.at(3)!.field.taxRate.set(19); // fully typed, one level per accessWhy
React's state model is pull-based and top-down. Deep enterprise forms want the opposite:
a keystroke on one field, a rule firing on the other side of the tree, hundreds of fields
on screen, and full type-safety on paths like company.departments[0].budget.lines[3].taxRate.
teff-rfc keeps the truth outside React in a small reactive graph, and lets React subscribe to exactly the leaves it renders.
| Goal | How teff-rfc delivers it |
| --- | --- |
| Deep type-safety | Field<T> resolves one level per property access — never a flat union of all paths, so it cannot hit "Type instantiation is excessively deep" at any depth. |
| Cross-field validation | Rules are plain functions. Reading another field inside a rule wires the dependency automatically — no dependsOn, and conditional edges are correct by construction. |
| Performance | Every value is a signal. A keystroke notifies only that field's subscribers; derived values recompute only when a real dependency changes. |
| Controlled state | Values live in the graph, never in uncontrolled DOM refs. |
| Clean API | Object access (f.a.b.set(x)), one hook per input (useField), stable-id arrays. |
Install
pnpm add @dccs/teff-rfc
# peer dependency: react >= 18
# runtime dependency: @dccs/teff-log (zero-dep; carries the diagnostics)Quick start
import { createForm, useForm, useField, required, email, type Leaf } from '@dccs/teff-rfc';
interface Signup {
name: string;
email: string;
}
function build() {
return createForm<Signup>({ name: '', email: '' }, (f) => {
f.name.rule(required('Name is required'));
f.email.rule(required(), email());
});
}
function TextInput({ field }: { field: Leaf<string> }) {
const { value, setValue, error, touch } = useField(field);
return (
<label>
<input value={value} onChange={(e) => setValue(e.target.value)} onBlur={touch} />
{error && <span className="error">{error}</span>}
</label>
);
}
function SignupForm() {
const form = useForm(build);
return (
<form onSubmit={() => console.log(form.validate(), form.values())}>
<TextInput field={form.field.name} />
<TextInput field={form.field.email} />
</form>
);
}The one idea
Fields, validation results, and derived values are all the same primitive: a cell in a reactive graph.
- A leaf field is a writable signal.
- A validation rule or a derived value is a derivation — a pure function of other cells.
- While a derivation runs, every cell it reads becomes a dependency edge, discovered automatically. Edges are rebuilt every run, so conditional dependencies are always correct.
- Change a cell → only its transitive dependents recompute, and only their leaf components repaint. Nothing in between re-renders.
┌───────────────────────────────────────────────┐
│ React components (paint only) │
│ useField / useArray / useWatch │ ← one subscription each
└───────────────┬───────────────────────────────┘
│ useSyncExternalStore
┌───────────────▼───────────────────────────────┐
│ teff-rfc engine — PLAIN JAVASCRIPT │
│ Signal (writable) Derivation (auto-tracked)│
│ + the typed proxy mirror │ ← all state & logic live here
└────────────────────────────────────────────────┘Cross-field rules wire themselves
No dependsOn. Reading a field inside a rule is the dependency.
createForm<Company>(defaults, (f) => {
// taxRate now depends on fiscalCountry — discovered from the read.
f.fiscal.taxRate.rule((v) =>
f.fiscal.fiscalCountry.get() === 'AT' ? (v ?? 0) <= 20 || 'Max 20% in AT' : true,
);
// The deepest dependency of all: a top-level cap vs the sum of the whole tree.
f.grandTotal.derive(() => sum(f.departments.items().map((d) => d.field.total.get())));
f.fiscal.maxBudget.rule((v) => v == null || f.grandTotal.get() <= v || 'Exceeds cap');
});Because edges are rebuilt on every run, when the AT branch isn't taken the edge to
fiscalCountry disappears — the rule can't be triggered by a field it no longer reads.
Arrays with stable identity
Items are stored by a stable id with a separate order list, so reorder / insert / remove never tears the subscriptions or dependency edges of the surviving items.
f.departments.each((dept) => {
dept.projects.each((project) => {
project.total.derive(() => sum(project.lines.items().map((l) => l.field.total.get())));
});
});
const id = f.departments.append(emptyDepartment()); // grows infinitely
f.departments.move(0, 2);
f.departments.remove(id);Validation
Rules are plain functions returning true (valid) or a message. The toolkit provides the
common ones; compose them by passing several to rule.
import { required, minLength, email, matches, when } from '@dccs/teff-rfc';
f.username.rule(required(), minLength(3));
f.email.rule(required(), email());
f.confirm.rule(matches(() => f.password.get(), 'Passwords do not match'));
f.vat.rule(when(() => isEU(f.country.get()), required('VAT required in the EU')));Async validation (e.g. server-side uniqueness) runs on change and is merged after the synchronous rules pass:
f.username.asyncRule(async (v) => ((await isTaken(v)) ? 'Already taken' : true), { debounce: 300 });
await form.validateAsync();Auto-tracking cannot follow reads across
await, so read any cross-field dependencies synchronously before the firstawaitinside an async rule.
Localized messages (i18n)
Rules never bake in a language. A validator can emit a deferred message — a catalog key plus optional params — that is resolved to text at render time, so switching language re-localizes every error with no form rebuild. The engine stays i18n-agnostic; it stores the key, never the string.
1. Register your catalog once (type-only, via declaration merging). A hook can't infer what you pass to a provider at runtime, so this is how teff-rfc learns your keys:
import type { AppMessages } from './i18n'; // e.g. export type AppMessages = typeof en;
declare module '@dccs/teff-rfc' {
interface Register {
messages: AppMessages;
}
}Now MessageKey is keyof AppMessages everywhere — msg('…') and custom codes are
key-checked against your catalog. (Params stay an untyped Record<string, string>, matching
a useMsgsWithParam-style API.) Skip registration and keys fall back to string.
Why a
declare module? A hook can't see the runtime value you pass to a provider, so the catalog is supplied at the type level via TypeScript declaration merging — the sameRegisterpattern TanStack Router and i18next use. Notes:
- It is type-only and zero-runtime — it compiles away entirely.
- Put it in any one
.ts/.d.tsfile that is part of your compilation (e.g. next to your i18n catalog, or a globalteff-rfc.d.ts). It is global; you write it once per app, never per form.- The module specifier must match the package name exactly:
'@dccs/teff-rfc'.AppMessagesis justkeyof-able —export type AppMessages = typeof enover your generated JSON catalog is the intended source.
2. Emit keys from rules:
import { required, min, custom, msg } from '@dccs/teff-rfc';
f.name.rule(required(msg('formValidationRequiredField')));
f.qty.rule(min(1, msg('formValidationMin', { min: '1' })));
f.name.rule(custom((v) => (v ? true : msg('formValidationNameTaken'))));
f.name.rule(required()); // no key → English default (escape hatch: plain strings pass through)3. Wrap the app once and feed your resolver — reusing the same provider that tracks dirty state:
import { TeffFormProvider } from '@dccs/teff-rfc';
function Root() {
const resolve = useMsgsWithParam(); // (key, params?) => string; changes on locale switch
return (
<TeffFormProvider resolveMessage={resolve} guardUnload>
<App />
</TeffFormProvider>
);
}useField(field).error and useFieldError(field) now return localized strings and update
live when the language changes. Imperative form.validate() returns { path, key, params }
so non-React code can resolve however it likes.
Unsaved-changes guard
The same provider tracks the dirty state of every form created with useForm (they register
automatically). Use it for save bars and navigation guards:
const dirty = useAnyFormDirty(); // true when any registered form is dirty
const resetAll = useResetAllForms(); // reset every dirty form
// <TeffFormProvider guardUnload> also arms a browser beforeunload prompt while dirty.Value-like objects (Date, Moment, Decimal)
A field compares values with Object.is. For a string, number, boolean or bigint that is
exactly right — identity and value are the same thing, so bigint needs nothing special here. For a
File it is also right: a different File genuinely is a different value.
It is wrong for a value-like object, where two instances can mean the same thing:
form.field.when.set(new Date('2026-01-01')); // the value it already held
form.isDirty(); // → true. Same instant, different object.Re-picking the date the user already chose marks the form dirty and fires your unsaved-changes guard. Tell the field what equality means and both stop — the write is dropped, nothing is notified, and dirty-tracking compares the same way:
const form = createForm<Contract>(defaults, (f) => {
f.validFrom.equals((a, b) => a?.valueOf() === b?.valueOf()); // Date, Moment, Dayjs
f.price.equals((a, b) => a.eq(b)); // Decimal.js, Money, …
});It is per field: siblings keep Object.is. It works through each() for array items. And it does
not rescue a value mutated in place —
moment.add(1, 'day');
form.field.validFrom.set(moment); // compares the object with itself— because no comparator can see a change that happened behind the field's back. That write is
half-applied: the new value is in values() and will submit, but the input never repaints and the
form never looks dirty. Replace, never mutate — the same rule React state has always had.
The simplest option remains a primitive. An ISO
YYYY-MM-DDstring needs no comparator, survivesJSON.stringify, and matches how dates usually cross the wire anyway.
Debugging
Forms fail quietly. A write that does not stick, a rule that fires on a value you did not
expect, a dirty flag that will not clear — none of it throws, and from the outside it all
looks like a React wiring problem. So the engine narrates itself through
@dccs/teff-log.
Nothing is stripped from production. The instrumentation ships and stays silent until the log level is raised, so a form misbehaving in a deployed app can be inspected where it misbehaves — no rebuild, no local reproduction:
// browser console, takes effect on the next change
globalThis.__teff__.logger.level = 'DEBUG';# or from the URL, for hash routers
https://app.example.com/#/invoices?teffLogLevel=DEBUG// or from application code
import { Logger, LogLevel } from '@dccs/teff-log';
Logger.setLevel(LogLevel.DEBUG);At DEBUG every change reports itself — how it was routed, which rule rejected what, and
every array mutation with the ids it touched:
2026-08-18T09:12:04.881Z [DEBUG] teff-rfc invoice set lines.k2.qty {to: 'number', as: 'leaf'}
2026-08-18T09:12:04.883Z [DEBUG] teff-rfc invoice rule failed lines.k2.qty {value: 'number', message: 'max 2 per line'}
2026-08-18T09:12:07.114Z [DEBUG] teff-rfc invoice append lines {id: 'k7', at: 3, value: 'object{qty, label}', length: 4}What is never logged
Values. Form values are user data — passwords, addresses, whatever someone typed — and
every record is also retained in the buffer Logger.getMessages() hands to whoever writes a
bug report. Since any end user can raise the level from the URL or the console, every record
must be safe at every level. So a value appears only as its kind and size — string(8),
array(3), object{city, zip}, Date instance — never as content. Paths, shapes, ids, rule
messages and counts answer nearly every question the values would; for the rest,
form.debug()'s return value hands the full data to the developer who explicitly asked,
without logging it.
One consequence worth knowing as the author of an app: rule messages are logged verbatim.
Write 'Max 20% in AT', not `${value} is too high` — interpolating the rejected value
into the message would carry it into the records.
Writes that cannot be applied warn by themselves
A value whose shape disagrees with the field's is accepted by the graph but dropped by the
projection — the write is real, values() never shows it. That is reported at WARN, which is
above the default level, so nobody has to have opted in first:
[WARN] teff-rfc invoice set validFrom {expected: 'branch', received: 'leaf',
reason: "the field's default makes \"validFrom\" a branch; a leaf written here is not visible in values()"}Note that field.set(null) on a nullable section type-checks — Field<T> intersects
Leaf<T> — so the compiler cannot catch this class of mistake. The warning can.
form.debug()
One call, for when a form does not do what the code says it should. It returns the full
snapshot to the caller, and logs a values-described copy at INFO (the retained buffer is
globally readable, so even an explicit report carries descriptions, not content):
const { dirty, errors, derived, reordered, arrays, values } = form.debug();| Field | Answers |
| --- | --- |
| dirty | Why is this form dirty? — every drifted path with initial and current. |
| reordered | The other reason it is dirty: arrays whose item order moved. |
| errors | Failing rules with the value that failed, not just the message. |
| derived | Paths backed by a derivation — the ones that reject writes. |
| arrays | Array paths mapped to their current item ids, in order. |
| validating / asyncRules | What is in flight, and what could be. |
| values, epoch, fields, touched, name, valid | The rest of the picture. |
Several forms on one screen stay apart via createForm(defaults, setup, { name: 'invoice' });
unnamed ones become form#1, form#2, … The epoch counter lets you tie a burst of log
lines to a single render.
For a bug report, Logger.getMessages() returns the retained trace — the last N records,
ready to paste.
API
createForm<T>(defaults, setup?, options?)
Creates a form. setup(field, form) runs once and is where derived values and rules are
declared. options.name labels the form in diagnostics (default form#1, form#2, …).
Returns a Form<T>.
Field<T>
The typed, lazy handle to a value. Resolves one level per property access.
Leaf fields
| Member | Description |
| --- | --- |
| get() / set(v) / update(fn) | Read / write / functional update. |
| reset() | Restore to the initial value. |
| valid() | true or the error message. |
| error() | Error message, or null when valid. |
| validating() | Whether an async rule is in flight. |
| isDirty() / isTouched() / touch() | Change- and interaction-state. |
| rule(...validators) | Register synchronous rules (first failure wins). |
| asyncRule(fn, opts?) | Register an async rule. |
| derive(fn) | Mark the field derived from other fields. |
| equals(fn) | Teach the field what unchanged means. See value-like objects. |
| path | The dot-path. |
Array fields
items(), length(), ids(), at(i), append, prepend, insert, remove(id),
removeAt(i), move, swap, clear, replace(values), reset, each(init),
rule(...), valid(), error().
Form<T>
field, values(), setValues(next), reset(), validate(), validateAsync(),
isValid(), isDirty(), errors(), stats(), subscribe(listener), debug().
Hooks
| Hook | Purpose |
| --- | --- |
| useForm(factory) | Create the form once; stable across renders. |
| useField(field) | Everything an input needs: { value, setValue, update, error, isValid, isValidating, isDirty, isTouched, touch, reset, path }. |
| useFieldValue(field) | Just the value (fine-grained). |
| useFieldError(field) | Just the error. |
| useArray(field) | { items, length, append, prepend, insert, remove, removeAt, move, swap, clear }. |
| useWatch(form, selector) | Read across fields; re-renders only when the selected value changes. |
| useFormValues(form) | The whole value object (debug panels, previews). |
| useFormStats(form) | { fields, dirty, errors }. |
| useAnyFormDirty() | true when any form under the provider is dirty. |
| useResetAllForms() | Resets every dirty form under the provider. |
Provider & i18n
<TeffFormProvider resolveMessage? guardUnload?>, msg(key, params?), and the augmentable
Register interface (MessageKey, MessageRef, MessageResolver).
Validators
required, minLength, maxLength, min, max, between, pattern, email, url,
integer, oneOf, matches, when, all, custom.
Honest limits
- "O(1)" is in affected nodes, not DOM operations. The one subscribed leaf still re-runs — that is React's floor. The win is that nothing else does.
- Async rules need the explicit
asyncRulepath; auto-tracking can't followawait. - No path-keyed config object. Rules live as functions on the field, which is what keeps both the type-checker and the graph fast.
- Model keys named exactly like a leaf method (
get,set,rule, …) resolve to the child field, not the method. Field names likeitems,length,appendare fully supported. - A plain object is always a section; anything else is one value.
Date,Map,Set,File, Moment/Dayjs and your own domain entities are held as they are, never spread into child fields. The types are looser than the runtime here: because TypeScript cannot see a prototype,Field<Moment>still offers the instance's own properties as child fields. Reading or writing the field as a whole is correct; navigating into it is not. A class instance used as a section therefore does not work — its keys are not child fields. - Change detection is
Object.isper field, not a deep compare. That is what keeps a keystroke from re-validating the form, and it is why a value-like object needsequals. Values mutated in place are invisible to it, by design. - Replacing a section with a single value does not stick.
set(null)on a nullable section, or a class instance onto a path whose default is a plain object, lands in the graph but is dropped by the projection. It type-checks, so the compiler will not stop you — the engine warns at runtime instead.
Development
pnpm install
pnpm test # vitest
pnpm run coverage # vitest + v8 coverage
pnpm run check # prettier + eslint + audit
pnpm run build # clean + check + madge + tsup (ESM + CJS + d.ts)Contributions are welcome — read CONTRIBUTING.md first: it explains the hard rules (one runtime dependency, React 18+), what kinds of MRs are accepted, and why most needs compose from the public API without touching the library at all.
License
MIT
