A headless, signal-native form coordination library for Angular 22. It manages field state and cross-field rules — you bring your own markup, your own component library, and your own layout.
No rendered components. No opinions on CSS. No adapters needed.
The library hands you signals. You write whatever HTML you want around them.
// define the form
const form = createForm<JobApplication>({
fields: [
{ key: 'employmentType', defaultValue: 'fullTime' },
{
key: 'dayRate',
rules: {
visible: {
dependentFields: ['employmentType'],
callback: (v) => v.employmentType === 'contract',
},
},
},
{
key: 'salary',
rules: {
visible: {
dependentFields: ['employmentType'],
callback: (v) => v.employmentType !== 'contract',
},
},
},
],
});<!-- your markup, your component library -->
<select [ngxField]="form.field('employmentType')">
<option value="fullTime">Full-time</option>
<option value="contract">Contract</option>
</select>
<div *ngxFieldVisible="form.field('salary')">
<label>Salary</label>
<input type="number" [ngxField]="form.field('salary')" />
</div>
<div *ngxFieldVisible="form.field('dayRate')">
<label>Day rate</label>
<input type="number" [ngxField]="form.field('dayRate')" />
</div>The library runs the rules reactively. You never call an update function or subscribe to anything.
Cross-field rules run inside Angular effect() calls. Each rule reads a snapshot of the form values it cares about, computes a result, then writes back via untracked() to prevent the effect re-triggering on its own write.
// inside createForm() — simplified
effect(() => {
const snapshot = buildSnapshot(fieldStates, rule.dependentFields);
const result = rule.callback(snapshot);
untracked(() => targetField.visible.set(result));
});untracked() is the load-bearing detail. Without it, setting visible inside an effect that reads form values would register visible as a dependency, and the effect would loop. With it, only the dependentFields values are tracked — writes are invisible to the reactive graph.
This pattern scales to three rule types:
| Rule | What it controls | Return type |
|---|---|---|
visible |
whether the field is in the DOM | boolean |
disabled |
whether the field is interactive | boolean |
value |
the field's current value | T[K] (typed to the field) |
value rules are the critical addition: auto-derive or auto-clear a field's value based on another field, declaratively in config. When availableImmediately is ticked, clear noticePeriod automatically:
{
key: 'noticePeriod',
rules: {
disabled: {
dependentFields: ['availableImmediately'],
callback: (v) => v.availableImmediately === true,
},
value: {
dependentFields: ['availableImmediately'],
callback: (v) => v.availableImmediately === true ? '' : undefined,
},
},
},value rules are also scoped via excludeKey internally — the field can never appear in its own snapshot, structurally preventing self-referential cycles without any cycle detection code.
Omitting dependentFields causes the rule to read every field, so it re-evaluates on any change anywhere. Providing it narrows the reactive dependency set:
// re-runs whenever any field changes
callback: (v) => v.roleType === 'management'
// re-runs only when roleType changes
dependentFields: ['roleType'],
callback: (v) => v.roleType === 'management'Both are correct. dependentFields is an optimisation, not a correctness requirement.
Each field exposes five signals via form.field(key):
interface FieldState<V> {
value: WritableSignal<V>; // read/write the field value
visible: Signal<boolean>; // controlled by visible rule
disabled: Signal<boolean>; // controlled by disabled rule
errors: Signal<string[]>; // computed from validators
touched: WritableSignal<boolean>; // set on blur by NgxField directive
}errors runs all validators on every value change and surfaces the full set — no short-circuit. valid on the form level deliberately excludes hidden fields: a stale invalid value on a hidden field doesn't block submission.
Built-in validators are factory functions:
import { required, min, max, minLength, maxLength, pattern } from 'ngx-form-signals';
{
key: 'coverNote',
validators: [required(), maxLength(500)],
}Each returns string | null. required treats null, undefined, and '' as empty — but not 0 or false, so numeric fields defaulting to zero don't fail presence checks. All other validators pass silently on empty values, deferring to required for presence.
Cross-field validation belongs in a rules.value handler rather than a validator — validators receive only the field's own value, not the rest of the form.
Four directives ship with the library, all scoped to native DOM elements:
[ngxField] — two-way binding for <input>, <select>, <textarea>. Writes value back on input/change, sets touched on blur, syncs disabled from the field state.
<input type="text" [ngxField]="form.field('firstName')" />
<input type="checkbox" [ngxField]="form.field('availableImmediately')" />
<select [ngxField]="form.field('noticePeriod')">...</select>*ngxFieldVisible (structural) — removes the element from the DOM entirely when visible() is false. Like *ngIf driven by the rule engine. Screen readers never see hidden content; nothing inside it keeps running.
<div *ngxFieldVisible="form.field('teamSize')">
<label>Team size</label>
<input type="number" [ngxField]="form.field('teamSize')" />
</div>[ngxFieldHidden] (attribute) — sets the native hidden attribute instead of removing the element. The field stays mounted; typed values and touched state survive hide/show cycles.
<div [ngxFieldHidden]="form.field('salary')">...</div><ngx-field-errors> — renders the first error string when the field is touched and has errors. Outputs a <span class="ngx-field-error"> with no built-in styles — ngx-field-error is a stable CSS hook.
<ngx-field-errors [field]="form.field('email')" />interface SignalForm<T> {
field<K extends keyof T>(key: K): FieldState<T[K]>;
value: Signal<Partial<T>>; // snapshot of all field values
valid: Signal<boolean>; // true when all visible fields have no errors
dirty: Signal<boolean>; // true when any field differs from its default
markAllTouched(): void; // call before submit to reveal all errors
reset(): void; // restores all values to defaults, clears touched
}Typical submit handler:
onSubmit() {
this.form.markAllTouched();
if (!this.form.valid()) return;
// this.form.value() is Partial<T> here
}The repo includes a demo app showing the same job application form built four ways:
| Route | Approach |
|---|---|
/reactive |
Reactive Forms — FormGroup, valueChanges subscriptions, imperative rule functions. The old way; kept as a baseline. |
/signal |
ngx-form-signals + Angular Material. Material components aren't native DOM elements, so visibility/errors are still wired by hand here — see the directives note below. |
/native |
ngx-form-signals + native HTML5 elements, using the NgxField / *ngxFieldVisible / <ngx-field-errors> directives |
/edit |
ngx-form-signals with async-loaded existing data, also using the directives — rules fire once, not twice |
The edit demo exists specifically to show that rules trigger exactly once against the real loaded values, not once empty and again once populated — a common pitfall when combining signal forms with async data.
pnpm install
# terminal 1 — rebuild library on changes
pnpm run build:lib:watch
# terminal 2 — serve demo at http://localhost:4300
pnpm startRun terminal 1 first so dist/ngx-form-signals exists before the demo starts.
# run tests
pnpm testThis was built starting from the premise that Angular had no reliable signal-native forms option — true while Signal Forms was experimental (Angular 21). As of Angular 22 (June 2026), @angular/forms/signals is stable and first-party, with a comparable FieldTree/field() model, reactive hidden()/disabled() rules, and native binding via [formField]. That closes the gap this library was originally built to fill.
It's kept here as-is: a working, tested, genuinely headless implementation, and the four-demo comparison remains a useful reference for the tradeoffs between Reactive Forms, a small headless signal library, and Angular's own official one. Known limitations versus the official API: no cycle detection across multi-field value rules, and no support for nested objects or array/repeat fields — see Roadmap below.
Features not yet implemented:
- Async validators — field-level async validation (e.g. check username availability via HTTP)
- Derived value from HTTP —
valuerules backed by async sources with debounce - Standard Schema validation — plug-in Zod, Valibot, or ArkType schemas directly
- Array / repeat fields — dynamic lists of sub-forms with scoped rule context
- Multi-page / wizard forms — step navigation with conditional page visibility
- Numeric input type —
valueAsNumberhandling and numeric-specific validators - Submission API — built-in submit lifecycle with server-side error routing back to fields
- Cycle detection — runtime warning when
valuerules form a dependency cycle
MIT — see LICENSE.