npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@signaltree/ng-forms

v7.3.4

Published

Angular forms as reactive JSON. Seamless SignalTree integration with FormTree creation, validators, and form state tracking.

Readme

@signaltree/ng-forms

Angular 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

Architecture: form() + formBridge()

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.

Quick Start (Recommended Pattern)

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: `
    <!-- Option 1: Use form() signals directly -->
    <input [value]="tree.$.checkout.shipping.$.name()" (input)="tree.$.checkout.shipping.$.name.set($event.target.value)" />

    <!-- Option 2: Use Angular FormGroup -->
    <form [formGroup]="shippingForm">
      <input formControlName="name" />
    </form>
  `,
})
class CheckoutComponent {
  tree = inject(CHECKOUT_TREE);

  // Get the FormGroup bridge
  shippingForm = this.tree.getAngularForm('checkout.shipping')?.formGroup;
}

When to Use Each Layer

form() alone (no ng-forms needed)

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('[email protected]');
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

form() + formBridge()

// 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

form() + formBridge() + withFormHistory()

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

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

import { Component } from '@angular/core';
import { createFormTree, required, email } from '@signaltree/ng-forms';

interface ProfileForm extends Record<string, unknown> {
  name: string;
  email: string;
  marketing: boolean;
}

@Component({
  selector: 'app-profile-form',
  template: `
    <form [formGroup]="profile.form" (ngSubmit)="save()">
      <input formControlName="name" placeholder="Name" />
      <span class="error" *ngIf="profile.getFieldError('name')()">
        {{ profile.getFieldError('name')() }}
      </span>

      <input formControlName="email" placeholder="Email" />
      <span class="error" *ngIf="profile.getFieldError('email')()">
        {{ profile.getFieldError('email')() }}
      </span>

      <label> <input type="checkbox" formControlName="marketing" /> Email marketing </label>

      <button type="submit" [disabled]="profile.valid() === false">
        {{ profile.submitting() ? 'Saving...' : 'Save profile' }}
      </button>
    </form>

    <pre>Signals: {{ profile.$.name() }} / {{ profile.$.email() }}</pre>
  `,
})
export class ProfileFormComponent {
  private storage = typeof window !== 'undefined' ? window.localStorage : undefined;

  profile = createFormTree<ProfileForm>(
    {
      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 submit

Core 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 ngModel

Angular 21 Interoperability

ng-forms complements Angular 21's native signal forms—use both in the same app:

Use Angular 21 FormField<T> for:

  • ✅ Simple, flat forms (login, search)
  • ✅ Single-field validation
  • ✅ Maximum type safety

Use ng-forms createFormTree() for:

  • ✅ 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

Hybrid Example: Simple Fields + Complex Tree

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
}

Connecting to Reactive Forms

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

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 once

Wizard flows

import { createWizardForm, FormStep } from '@signaltree/ng-forms';

const steps: FormStep<AccountSetup>[] = [
  {
    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

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 errors

Template-driven bridge

<input type="text" [(ngModel)]="userName" [signalTreeSignalValue]="formTree.$.user.name" (signalTreeSignalValueChange)="audit($event)" />

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.

Before (deprecated)

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

After (recommended)

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

Key differences

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

Migration steps

  1. Move form state into your SignalTree using form() marker
  2. Add .with(formBridge()) to your tree
  3. Update access patterns: form.$.fieldtree.$.formName.$.field
  4. Update FormGroup access: form.formtree.getAngularForm('path')?.formGroup

Links

License

MIT License with AI Training Restriction — see the LICENSE file for details.


Seamless signal-first Angular forms.