Skip to content

Repository files navigation

ngx-form-signals

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.


What "headless" means here

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.


The rule engine

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.


dependentFields — performance hint

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.


Field state

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.


Validators

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.


Directives

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')" />

Form-level API

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
}

Demo

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.


Running locally

pnpm install

# terminal 1 — rebuild library on changes
pnpm run build:lib:watch

# terminal 2 — serve demo at http://localhost:4300
pnpm start

Run terminal 1 first so dist/ngx-form-signals exists before the demo starts.

# run tests
pnpm test

Status

This 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.


Roadmap

Features not yet implemented:

  • Async validators — field-level async validation (e.g. check username availability via HTTP)
  • Derived value from HTTP — value rules 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 — valueAsNumber handling and numeric-specific validators
  • Submission API — built-in submit lifecycle with server-side error routing back to fields
  • Cycle detection — runtime warning when value rules form a dependency cycle

License

MIT — see LICENSE.

About

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

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages