Angular components increasingly use signals for state, while forms still live in FormGroup with RxJS valueChanges — and the obvious bridge, toSignal(form.valueChanges), returns undefined until the first change, misses status changes caused by async validators, and invites effects that write back into the form and loop.

This page builds a small, reusable bridge: signals for value, status, errors and dirty/touched state that are correct from the first render, computed values derived without subscriptions, and a controlled path for pushing signal state into the form. It extends the adapter architecture in Angular reactive forms adapters and builds on typed reactive forms in Angular.


Context and prerequisites

The observables a control exposes:

  • valueChanges — emits on value changes; does not emit the initial value.
  • statusChanges — emits VALID, INVALID, PENDING, DISABLED; does not emit the initial status.
  • events (Angular 18+) — a unified stream of ValueChangeEvent, StatusChangeEvent, TouchedChangeEvent, PristineChangeEvent, FormSubmittedEvent and FormResetEvent. This is the only stream that reports touched and pristine changes.

toSignal(obs$, { initialValue }) converts an observable to a signal, subscribing for the lifetime of the injection context. The initial value matters: without it, the signal is undefined until the first emission — i.e. until the user types.

What each form stream reports valueChanges does not emit its initial value and covers value changes only. statusChanges does not emit its initial status and covers validity including PENDING from async validators. The unified events stream, available from Angular 18, covers value, status, touched, pristine, submit and reset events, and is the only way to observe touched and pristine changes. Stream Emits initial? Covers valueChanges no value statusChanges no VALID / INVALID / PENDING / DISABLED events (18+) no value, status, touched, pristine, submit, reset

The core pattern: a form-state signal that starts correct

import { computed, DestroyRef, effect, inject, Signal, signal, untracked } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { AbstractControl, FormGroup } from "@angular/forms";
import { map, startWith } from "rxjs/operators";

export interface ControlSnapshot<T> {
  value: T;
  status: AbstractControl["status"];
  errors: AbstractControl["errors"];
  touched: boolean;
  dirty: boolean;
}

// Snapshot the control on every event, starting with its current state.
export function controlState<T>(control: AbstractControl<T>): Signal<ControlSnapshot<T>> {
  const snap = (): ControlSnapshot<T> => ({
    value: control.value, status: control.status, errors: control.errors,
    touched: control.touched, dirty: control.dirty,
  });
  return toSignal(
    control.events.pipe(map(snap), startWith(snap())),
    { initialValue: snap() },     // correct on first render, before any event
  );
}

// Usage inside a component (an injection context):
export class CheckoutComponent {
  form = inject(CheckoutFormFactory).create();
  private state = controlState(this.form);

  // Derived UI state with no subscriptions to manage.
  readonly canSubmit = computed(() => this.state().status === "VALID");
  readonly checking = computed(() => this.state().status === "PENDING");
  readonly total = computed(() => {
    const v = this.state().value;
    return v.items.reduce((sum, i) => sum + i.qty * i.unitPrice, 0);
  });

  // Signal → form: a discount code signal applied to the form, guarded against loops.
  readonly appliedCode = signal<string | null>(null);
  constructor() {
    effect(() => {
      const code = this.appliedCode();
      untracked(() => {
        const ctrl = this.form.controls.discountCode;
        if (ctrl.value !== code) ctrl.setValue(code ?? "", { emitEvent: true });
      });
    });
  }
}
declare class CheckoutFormFactory { create(): FormGroup<any>; }

Step-by-step walkthrough

  1. Convert the unified events stream. It covers status (including PENDING from async validators), touched and pristine, which valueChanges alone cannot.
  2. Seed with the current snapshot. startWith(snap()) plus initialValue makes the signal correct at first render, so templates never show undefined.
  3. Snapshot, do not stream deltas. Mapping every event to a full snapshot keeps the signal’s value self-consistent; computed readers get value and status from the same moment.
  4. Derive with computed. Totals, “can submit”, “is checking” — no subscriptions, no takeUntilDestroyed, recalculated only when the snapshot changes.
  5. Write from signals to the form inside untracked, with an equality guard. The effect should depend only on the source signal; reading form state inside it (tracked) or writing unconditionally creates a loop.
  6. Keep the form the source of truth for field values. Signals are views and occasional inputs; the FormGroup still owns values, validation and status, so markAllAsTouched, reset and validators keep working.

Why not replace the form with signals entirely?

Moving every field’s value into a signal and validating with computed is attractive, and signal-first form APIs are the direction Angular is heading. For existing reactive forms, though, the FormGroup provides behaviour that a set of signals does not: validator composition, async validator status, disabled-state participation, markAllAsTouched, the value accessors that connect custom controls, and a large ecosystem of controls built on ControlValueAccessor. A bridge lets components adopt signals for everything around the form — derived UI, cross-component state, template logic — without rewriting the form layer. When a signal-based form API is adopted, the same derived computed values can be kept and pointed at the new source.

Direction of data across the bridge From form to signals, the FormGroup's events stream is mapped to snapshots and converted with toSignal, seeded with the current state; computed signals derive totals, submit readiness and pending state from it. From signals to the form, an effect reads one source signal and, inside untracked with an equality guard, sets the value of one control, which then flows back through the events stream. FormGroup → signals events → snapshot → toSignal. Seeded with current state. computed: total, canSubmit, checking. Signal → one control effect reads the source signal. untracked + equality guard. setValue flows back via events.

Zoneless change detection and the bridge

Applications moving to zoneless change detection rely on signals to tell Angular what changed. Reactive forms update their own state synchronously, but without Zone.js nothing schedules a render when an async validator resolves or a control’s status flips outside an event handler. A signal bridge closes that gap: because the snapshot signal changes on every form event, templates that read it are marked for update exactly when the form changes, including after asynchronous validation. In zoneless applications, reading form state in templates through the bridge rather than through form.controls.x.status directly is what keeps “Checking…” indicators and submit readiness from going stale.


Failure modes and edge cases

1. undefined on first render

toSignal(form.valueChanges) without initialValue is undefined until the first change, so a computed total renders NaN or throws. Always seed.

2. Effects that loop

An effect that reads this.state() and calls setValue re-triggers itself through the events stream. Read only the source signal in the effect body, perform form writes inside untracked, and skip writes that would not change the value.

3. Missing PENDING

A signal built from valueChanges shows VALID while an async validator is still running, enabling submit too early. Use events or statusChanges; the async side is covered in cancelling Angular async validators with switchMap.

4. Calling toSignal outside an injection context

toSignal needs an injection context (constructor, field initialiser, or runInInjectionContext) to clean up on destroy. Creating bridges in ngOnInit throws unless you pass an explicit injector.

5. Performance with large forms

Snapshotting the whole form on every event is fine for typical forms. For forms with hundreds of controls, bridge individual controls (controlState(form.controls.email)) where signals are needed, instead of the whole group, following the isolation idea in memoization boundaries for form fields.

Which bridge for which need If you need touched or pristine state, use the unified events stream. If you need async validation pending status, use events or statusChanges with a seeded initial value. If you only need the current value for derived display, toSignal of valueChanges with startWith and an initial value is enough. Need touched or pristine? control.events yes no Need PENDING from async validators? events or statusChanges yes no valueChanges + startWith Value-only derived display.

Verification checklist


Frequently Asked Questions

Is toSignal subscription cleanup automatic?

Yes, when created in an injection context: the subscription ends when the component or service is destroyed. Outside one, pass { injector } or { manualCleanup: true } and unsubscribe yourself.

Can I use signals in the template instead of form.controls?

For display — totals, status badges, conditional sections — yes, and it simplifies change detection with zoneless setups. Keep formControlName or [formControl] bindings for the inputs themselves, since they carry the value accessor wiring.

Does the events stream exist in older Angular versions?

It arrived in Angular 18. On earlier versions, combine valueChanges and statusChanges with startWith for value and status, and track touched yourself on blur, since older versions do not emit touched changes.


Related

← Angular Reactive Forms Adapters