Angular forms as reactive JSON. Seamless SignalTree integration with FormTree creation, validators, and form state tracking.
npm install @signaltree/ng-formsAngular FormGroup bridge for SignalTree's form() marker. Adds reactive forms integration, conditional fields, and undo/redo to tree-integrated forms.
Bundle size: 3.38KB gzipped
SignalTree v7 introduces a layered forms architecture:
```
@signaltree/core @signaltree/ng-forms
┌─────────────────────────┐ ┌─────────────────────────┐
│ form() marker │ │ formBridge() │
│ ─────────────────────── │ ───► │ enhancer that: │
│ • Signal-based fields │ │ • Creates FormGroup │
│ • Sync/async validators │ │ • Bidirectional sync │
│ • Persistence │ │ • Conditional fields │
│ • Wizard navigation │ │ • Angular validators │
│ • dirty/valid/submitting│ │ │
└─────────────────────────┘ │ withFormHistory() │
Works standalone! │ • Undo/redo │
└─────────────────────────┘
Key insight: form() is self-sufficient. formBridge() adds Angular-specific capabilities.
`typescript
import { signalTree, form } from '@signaltree/core';
import { formBridge } from '@signaltree/ng-forms';
// Define forms in your tree
const tree = signalTree({
checkout: {
shipping: form({
initial: { name: '', address: '', zip: '' },
validators: {
zip: (v) => (/^\d{5}$/.test(String(v)) ? null : 'Invalid ZIP'),
},
persist: 'checkout-shipping',
}),
payment: form({
initial: { card: '', cvv: '' },
wizard: { steps: ['card', 'review'] },
}),
},
}).with(
formBridge({
conditionals: [{ when: (v) => v.checkout.sameAsBilling, fields: ['checkout.shipping.*'] }],
})
);
// Use in components
@Component({
template:
,
})
class CheckoutComponent {
tree = inject(CHECKOUT_TREE); // Get the FormGroup bridge
shippingForm = this.tree.getAngularForm('checkout.shipping')?.formGroup;
}
`When to Use Each Layer
$3
`typescript
import { signalTree, form } from '@signaltree/core';
import { email } from '@signaltree/ng-forms';// Pure signal forms - works without Angular forms module
const tree = signalTree({
login: form({
initial: { email: '', password: '' },
validators: { email: email() },
}),
});
// Full functionality without Angular FormGroup
tree.$.login.$.email.set('user@test.com');
tree.$.login.valid(); // Reactive validation
tree.$.login.validate(); // Trigger validation
tree.$.login.submit(fn); // Submit handling
tree.$.login.wizard?.next(); // Wizard navigation (if configured)
`Use when: SSR, unit tests, simple forms, non-Angular environments
$3
`typescript
// Add Angular FormGroup bridge
const tree = signalTree({
profile: form({ initial: { name: '' } }),
}).with(formBridge());// Now you get FormGroup access
const formGroup = tree.getAngularForm('profile')?.formGroup;
// Or attached directly: (tree.$.profile as any).formGroup
`Use when: Need
[formGroup] directives, Angular validators, conditional field disabling$3
`typescript
const tree = signalTree({
editor: form({ initial: { content: '' } }),
})
.with(formBridge())
.with(withFormHistory({ capacity: 50 }));tree.undo();
tree.redo();
`Use when: Complex editors, need undo/redo
Installation
`bash
pnpm add @signaltree/core @signaltree/ng-forms
`> Compatibility: Angular 17+ with TypeScript 5.5+. Angular 21+ recommended for best experience. Works alongside Angular's native signal forms—use both where appropriate.
Quick start
`typescript
import { Component } from '@angular/core';
import { createFormTree, required, email } from '@signaltree/ng-forms';interface ProfileForm extends Record {
name: string;
email: string;
marketing: boolean;
}
@Component({
selector: 'app-profile-form',
template:
Signals: {{ profile.$.name() }} / {{ profile.$.email() }},
})
export class ProfileFormComponent {
private storage = typeof window !== 'undefined' ? window.localStorage : undefined; profile = createFormTree(
{
name: '',
email: '',
marketing: false,
},
{
persistKey: 'profile-form',
storage: this.storage,
fieldConfigs: {
name: { validators: [required('Name is required')] },
email: {
validators: [required(), email()],
debounceMs: 150,
},
},
}
);
async save() {
await this.profile.submit(async (values) => {
// Persist values to your API or service layer here
console.log('Saving profile', values);
});
}
}
`The returned
FormTree exposes:-
form: Angular FormGroup for templates and directives
- $ / state: signal-backed access to individual fields
- errors, asyncErrors, valid, dirty, submitting: writable signals for UI state
- Helpers such as setValue, setValues, reset, validate, and submitCore capabilities
- Signal-synced forms: Bidirectional sync between Angular FormControls and SignalTree signals
- Per-field configuration: Debounce, sync & async validators, and wildcard matcher support
- Conditional fields: Enable/disable controls based on dynamic predicates
- Persistence: Keep form state in
localStorage, IndexedDB, or custom storage with debounced writes
- Validation batching: Aggregate touched/errors updates to avoid jitter in large forms
- Wizard & history helpers: Higher-level APIs for multi-step flows and undo/redo stacks
- Signal ↔ Observable bridge: Convert signals to RxJS streams for interoperability
- Template-driven adapter: SignalValueDirective bridges standalone signals with ngModelAngular 21 Interoperability
ng-forms complements Angular 21's native signal forms—use both in the same app:
$3
- ✅ Simple, flat forms (login, search)
- ✅ Single-field validation
- ✅ Maximum type safety
$3
- ✅ Nested object structures (user + address + payment)
- ✅ Forms with persistence/auto-save
- ✅ Wizard/multi-step flows
- ✅ History/undo requirements
- ✅ Complex conditional logic
- ✅ Migration from reactive forms
$3
`typescript
import { formField } from '@angular/forms';
import { createFormTree } from '@signaltree/ng-forms';@Component({...})
class CheckoutComponent {
// Simple field: Use Angular 21 native
promoCode = formField('');
// Complex nested state: Use ng-forms
checkout = createFormTree({
shipping: { name: '', address: '', city: '', zip: '' },
payment: { card: '', cvv: '', expiry: '' },
items: [] as CartItem[]
}, {
persistKey: 'checkout-draft',
fieldConfigs: {
'shipping.zip': { validators: [(v) => /^\d{5}$/.test(String(v)) ? null : 'Invalid ZIP'] },
'payment.card': { validators: [(v) => /^\d{13,19}$/.test(String(v)) ? null : 'Invalid card'], debounceMs: 300 }
}
});
// Both work together seamlessly
}
`$3
`ts
import { toWritableSignal } from '@signaltree/core';// Convert ng-forms signals to work with Angular's .connect()
const nameSignal = toWritableSignal(formTree.$.user.name);
reactiveControl.connect(nameSignal);
`Form tree configuration
`typescript
const checkout = createFormTree(initialState, {
validators: {
'shipping.zip': (value) => (/^[0-9]{5}$/.test(String(value)) ? null : 'Enter a valid ZIP code'),
},
asyncValidators: {
'account.email': async (value) => ((await emailService.isTaken(value)) ? 'Email already used' : null),
},
fieldConfigs: {
'payment.card.number': { debounceMs: 200 },
'preferences.*': { validators: [required()] },
},
conditionals: [
{
when: (values) => values.shipping.sameAsBilling,
fields: ['shipping.address', 'shipping.city', 'shipping.zip'],
},
],
persistKey: 'checkout-draft',
storage: sessionStorage,
persistDebounceMs: 500,
validationBatchMs: 16,
});
`-
validators / asyncValidators: Map paths (supports * globs) to declarative validation functions
- fieldConfigs: Attach validators and per-field debounce without scattering logic
- conditionals: Automatically disable controls when predicates fail
- persistKey + storage: Load persisted values on creation and auto-save thereafter
- validationBatchMs: Batch aggregate signal updates when running lots of validators at onceWizard flows
`typescript
import { createWizardForm, FormStep } from '@signaltree/ng-forms';const steps: FormStep[] = [
{
fields: ['profile.name', 'profile.email'],
validate: async (form) => {
await form.validate('profile.email');
return !form.getFieldError('profile.email')();
},
},
{
fields: ['security.password', 'security.confirm'],
},
];
const wizard = createWizardForm(steps, initialValues, {
conditionals: [
{
when: ({ marketingOptIn }) => marketingOptIn,
fields: ['preferences.frequency'],
},
],
});
await wizard.nextStep();
wizard.previousStep();
wizard.currentStep(); // readonly signal
wizard.isFieldVisible('preferences.frequency')();
`Wizard forms reuse the same
form instance and FormTree helpers, adding currentStep, nextStep, previousStep, goToStep, and isFieldVisible helpers for UI state.Form history snapshots
`typescript
import { withFormHistory } from '@signaltree/ng-forms';const form = withFormHistory(createFormTree(initialValues), { capacity: 20 });
form.setValue('profile.name', 'Ada');
form.undo();
form.redo();
form.history(); // signal with { past, present, future }
form.clearHistory();
`History tracking works at the FormGroup level so it plays nicely with external updates and preserved snapshots.
Helpers and utilities
-
validators / asyncValidators: Lightweight factories for common rules (required, email, minLength, unique, etc.)
- createVirtualFormArray: Virtualize huge FormArrays by only instantiating the visible window
- toObservable(signal): Convert any Angular signal to an RxJS Observable
- SIGNAL_FORM_DIRECTIVES: Re-export of SignalValueDirective for template-driven helpers
- FormValidationError: Error thrown from submit when validation fails, containing sync & async errorsTemplate-driven bridge
`html
`Use
SignalValueDirective to keep standalone signals and ngModel fields aligned in legacy sections while new pages migrate to forms-first APIs.When to use ng-forms vs Angular 21 signal forms
| Scenario | Recommendation |
| ------------------------------------------ | ---------------------------------------- |
| Login form (2-3 fields) | ✅ Angular 21
FormField |
| Search bar with filters | ✅ Angular 21 FormField |
| User profile with nested address | ✅ ng-forms (tree structure) |
| Checkout flow (shipping + payment + items) | ✅ ng-forms (persistence + wizard) |
| Multi-step onboarding (5+ steps) | ✅ ng-forms (wizard API) |
| Form with auto-save drafts | ✅ ng-forms (built-in persistence) |
| Complex editor with undo/redo | ✅ ng-forms (history tracking) |
| Migrating from reactive forms | ✅ ng-forms (FormGroup bridge) |
| Dynamic form with conditional fields | ✅ ng-forms (conditionals config) |
| Form synced with global app state | ✅ ng-forms (SignalTree integration) |Rule of thumb: If your form data is a nested object or needs workflow features (persistence/wizards/history), use ng-forms. For simple flat forms, Angular 21's native signal forms are perfect.
Migration from createFormTree()
createFormTree() is deprecated in favor of the composable form() + formBridge() pattern.$3
`typescript
import { createFormTree, email } from '@signaltree/ng-forms';const form = createFormTree(
{
name: '',
email: '',
},
{
validators: { email: email() },
persistKey: 'profile-form',
}
);
// Access
form.$.name.set('John');
form.form; // FormGroup
`$3
`typescript
import { signalTree, form } from '@signaltree/core';
import { formBridge, email } from '@signaltree/ng-forms';const tree = signalTree({
profile: form({
initial: { name: '', email: '' },
validators: { email: email() },
persist: 'profile-form',
}),
}).with(formBridge());
// Access
tree.$.profile.$.name.set('John');
tree.getAngularForm('profile')?.formGroup; // FormGroup
// Or: (tree.$.profile as any).formGroup
`$3
| Aspect | createFormTree() | form() + formBridge() |
| -------------------- | ----------------------- | ---------------------------- |
| Standalone | Always needs Angular | form() works without Angular |
| Tree integration | Separate from app state | Lives in your main tree |
| DevTools | Separate | Inherits tree DevTools |
| Composability | Limited | Add enhancers freely |
| Tree-shaking | All-or-nothing | Only what you use |
$3
1. Move form state into your SignalTree using
form() marker
2. Add .with(formBridge()) to your tree
3. Update access patterns: form.$.field → tree.$.formName.$.field
4. Update FormGroup access: form.form → tree.getAngularForm('path')?.formGroup`- SignalTree Documentation
- Angular 21 Migration Guide
- Core Package
- GitHub Repository
- Demo Application
MIT License with AI Training Restriction — see the LICENSE file for details.
---
Seamless signal-first Angular forms.