| name | signaltree-ng-forms |
| description | Guides AI agents integrating Angular reactive forms with SignalTree via @signaltree/ng-forms. Covers FormGroup ↔ signal bidirectional sync, validators, conditional fields, persistence, undo/redo, and multi-step wizards. Triggers on @signaltree/ng-forms, Angular reactive forms, FormGroup, createFormTree, form() marker, formBridge, signal forms, FormControl, FormArray, form validators, wizard forms, withFormHistory, createWizardForm, form history, undo redo forms. |
Using @signaltree/ng-forms
Use when an Angular component needs both a SignalTree-backed state slice (reactive in templates/computed/effect) and a native Angular FormGroup/FormControl for ReactiveFormsModule, third-party UI libs, or ControlValueAccessor interop.
Install:
npm install @signaltree/core @signaltree/ng-forms
Peer: @angular/core ^20, @angular/forms ^20, rxjs ^7.
Two patterns — choose one:
- Pattern B:
form() marker + formBridge() — recommended for new code. Form is one slice of a larger tree.
- Pattern A:
createFormTree() — when entire component is a form and you want all helpers on one object. Emits dev-only deprecation note; fully functional.
Pattern A — createFormTree (full example with validators, async validation, conditionals, persistence):
import { createFormTree, ngFormValidators } from '@signaltree/ng-forms';
const { required, email, minLength, pattern } = ngFormValidators;
interface ProfileForm extends Record<string, unknown> {
name: string; email: string; role: string; company: { name: string; size: string }
}
class ProfileComponent {
emailAvailabilityValidator: any = null;
readonly profile = createFormTree<ProfileForm>(
{ name: '', email: '', role: 'individual', company: { name: '', size: '1-10' } },
{
persistKey: 'profile-form',
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
validationBatchMs: 120,
fieldConfigs: {
name: { validators: [required(), minLength(3)] },
email: {
validators: [required(), email()],
asyncValidators: [this.emailAvailabilityValidator],
debounceMs: 180,
},
'company.name': { validators: [pattern(/^[A-Za-z0-9 .,'&-]{2,}$/)] },
},
conditionals: [
{ when: (v) => v.role === 'manager', fields: ['company.name'] },
],
}
);
async save() {
try {
await this.profile.submit(async (values) => { });
} catch { }
}
}
Bind template: [formGroup]="profile.form". Read signal: profile.$.name(). Read error: profile.getFieldError('email')().
Pattern B — form() marker + formBridge():
import { form, FormSignal, signalTree, validators } from '@signaltree/core';
import { formBridge } from '@signaltree/ng-forms';
const store = signalTree({
contact: form<ContactForm>({
initial: { name: '', email: '', message: '' },
validators: {
name: validators.required('Name is required'),
email: [validators.required(), validators.email()],
message: [validators.required(), validators.minLength(10)],
},
}),
}).with(formBridge());
const bridge = store.getAngularForm('contact');
formBridge() auto-discovers all form() markers in the tree, including nested paths (tree.getAngularForm('user.profile')).
Pattern C — wizard via form() marker:
import { form, signalTree } from '@signaltree/core';
interface SignupForm extends Record<string, unknown> {
email: string; password: string; firstName: string; lastName: string
}
const tree = signalTree({
signup: form<SignupForm>({
initial: { email: '', password: '', firstName: '', lastName: '' },
wizard: {
steps: ['credentials', 'profile'],
stepFields: { credentials: ['email', 'password'], profile: ['firstName', 'lastName'] },
},
}),
});
See WizardConfig and FormWizard in @signaltree/core for full shape.
Pattern D — undo/redo, via core history() (v13+; recommended, works with both form() alone and a bound signalForm()):
import { signalTree, form, history } from '@signaltree/core';
interface ContactForm extends Record<string, unknown> { name: string; email: string; ssn: string }
const tree = signalTree({
contact: form<ContactForm>({
initial: { name: '', email: '', ssn: '' },
history: history({ capacity: 20, exclude: ['ssn'] }),
}),
});
tree.$.contact.patch({ name: 'Ada' });
tree.$.contact.history?.undo();
tree.$.contact.history?.redo();
tree.$.contact.history?.canUndo();
tree.$.contact.history?.history();
A raw object on history (not history()'s output) throws [ST2006] at the form() call. If a signalForm() binds this same marker, undo/redo also move the bound FieldTree, and edits made through the FieldTree are captured too — one engine, no sync loop.
Pattern D2 — undo/redo WITHOUT a marker, via core trackHistory() (v13.1+). For a plain Angular Signal Forms model signal (no SignalTree form() marker), point trackHistory at the model to get the same undo/redo engine — replaces @ngrx-style change trackers. Needs an injection context (or pass injector):
import { signal, inject, Injector } from '@angular/core';
import { form, required } from '@angular/forms/signals';
import { trackHistory } from '@signaltree/core';
const model = signal({ title: '', priority: 'low' });
const f = form(model, (p) => { required(p.title); });
const hist = trackHistory(model, { capacity: 25, exclude: ['secret'], injector: inject(Injector) });
hist.undo(); hist.redo(); hist.canUndo();
Pattern E — pass an Angular Signal Forms schema + FormOptions to a marker-bridged form (v13.1+). signalForm(marker, options) takes schema as a SchemaOrSchemaFn<T> (inline SchemaFn OR a cached schema() object) for rules a marker's validators can't hold (disabled/hidden/applyEach/cross-field validate/validateAsync), and forwards name/submission/experimentalWebMcpTool verbatim to Angular's form(model, schema, options):
import { disabled, schema, validate } from '@angular/forms/signals';
import { signalForm } from '@signaltree/ng-forms/signals';
const s = schema<Profile>((p) => { disabled(p.email, { when: (c) => !c.valueOf(p.optIn) }); });
const fieldTree = signalForm(tree.$.profile, {
injector,
schema: s,
experimentalWebMcpTool: { name: 'update_profile', description: 'Fill/submit the profile form' },
});
Schema-level validateAsync is the supported async shape (a marker with its own asyncValidators still throws [ST2005]; put async in the passed schema instead).
Legacy Pattern D — withFormHistory on createFormTree (@deprecated since v13; retained for createFormTree/FormGroup users, cannot attach to a signalForm() FieldTree):
import { createFormTree } from '@signaltree/ng-forms';
import { withFormHistory } from '@signaltree/ng-forms';
interface ContactForm extends Record<string, unknown> { name: string; email: string }
const formTree = createFormTree<ContactForm>({ name: '', email: '' });
const formWithHistory = withFormHistory(formTree, { capacity: 20 });
formWithHistory.undo();
formWithHistory.redo();
formWithHistory.history().past.length;
Key contracts:
- Conditional fields:
when predicate false → Angular control disabled + excluded from validation. Persisted state respects condition on hydration.
- Persistence:
persistKey + storage (any Storage-shaped object). persistDebounceMs defaults to 100ms. Pass undefined for storage in SSR.
validationBatchMs: 0 = instant feedback; non-zero = coalesce for async validators.
- Field paths: dotted strings (
'company.name'). FormArray entries use numeric segments ('phoneNumbers.0.value').
SIGNAL_FORM_DIRECTIVES exported for directive-based binding in imports: arrays.
- Don't call
createFormTree outside an injection context without passing destroyRef explicitly.
FormGroup is source of truth for dirty/touched; don't write them directly on the signal. Call markAsTouched() on the control.
- Arrays: use
.push, .removeAt, .setAt, .insertAt, .move, .clear — don't use .set([...]) for per-item updates; breaks FormArray sync.
required() treats false, 0, '' as missing. For boolean-must-be-true (accept terms), write a custom FieldValidator.
Gotchas:
FormValidationError thrown from submit() — always wrap in try/catch.
dirty/touched from signal tree = mirrored value; write via Angular control methods only.
- Arrays: use mutation methods, not
.set([...]).
required() won't work for boolean-must-be-true; use a custom validator.
Related: using-signaltree (root), spec-auditing, compression