Why hold out for an official reactive forms plus signals solution when you can craft your own on the spot? Take a proven approach from one of the most respected voices in the reactive forms arena (Joshua Morony), add a few twists of your own, and you’ll be tapping into signals alongside reactive forms without delay. By leaning on straightforward methods that bridge RxJS and signals, you’ll start reacting to value and status changes in reactive forms through signals as far back as Angular 16. What’s more, with the newer form events API arriving in Angular 18, this forward-looking technique stands to improve as time goes on.

What the Final Result Looks Like

If you stick to the steps, this signature is what you’ll end up with.

// Disclaimer: T is most often inferred as Partial<T> in practice due to a limitation of form typing
// See more, and how in the future this utility could be enhanced further 
// https://github.com/ngxtension/ngxtension-platform/pull/391#issuecomment-2163512231

type FormEventData<T> = {
  // These values are possible in Angular 16, see links at the bottom
  value: T;
  status: FormControlStatus;
  valid: boolean;
  invalid: boolean;
  pending: boolean;

  // These values are possible as of Angular 18
  touched: boolean;
  pristine: boolean;
  dirty: boolean;
  untouched: boolean;
};

function allEventsObservable<T>(form: AbstractControl<T>): Observable<FormEventData<T>>

function allEventsSignal<T>(form: AbstractControl<T>): Signal<FormEventData<T>>

Return signature

Preview of return signature with live values from a form control
Preview of return signature with live values from a form control

Familiar Ingredients And Process

No internet recipe is complete without a bit of origin story, right? Trust me, it matters! Knowing how things were tackled in the past sheds light on this new way of handling form events.

Where did this idea come from? A snippet of the talk "SIGNALS can make Angular "REACTIVE" forms more reactive" by Joshua Morony, a true expert in building observable systems. That talk goes deeper than we will here, but the essential utility shows up at 3:31, where he introduces the helper formValues(this.form).

// https://github.com/joshuamorony/signal-slice-forms/blob/main/src/app/shared/utils/signal-forms.ts#L5
import { FormGroup } from '@angular/forms';
import { map } from 'rxjs/operators';

export function formValues(form: FormGroup) {
  return form.valueChanges.pipe(map(() => form.getRawValue()));
}

This concoction has stood the test of time, even as Angular 18 introduces fresh elements to the mix. By leveraging the idea of crafting utilities that respond to form updates via form.valueChanges and form.statusChanges, we can whip up form signals. Yet, before diving in, we need to adjust the foundation slightly.

A crucial aspect of these utilities is their ability to not only capture value and status on change, but also to provide starting values. Josh's complete solution sets the initial form state in his own manner, but with our method, we can always guarantee the value stream kicks off with a value using the RXJS operator startWith. As an added perk, we’ll sprinkle in a distinctUntilChanged to trim redundant emissions from the value stream, plus a generic T for enhanced type safety.

Whole Brew: Pulling value/status/touched/pristine as Signals and Observables via a Fresh Angular 18 Feature

The merge request integrated into Angular 18, named "Unified Control State Change Events #54579", brought in an observable for forms called events, which hands over a flow of events along with their corresponding values. For a thorough explanation of its mechanics, check out this outstanding concise video from Igor Sedov, "New in Angular 18: Unified Control State Change Events for Forms".

Event categories (each extend ControlEvent)

  • ValueChangeEvent
  • StatusChangeEvent
  • TouchedChangeEvent
  • PristineChangeEvent
  • FormResetEvent (lacks a value accessor)
  • FormSubmittedEvent (lacks a value accessor)

This approach won’t cover helpers for capturing a form’s reset or submitted states since those don’t come with initial values. Tackling those would demand separate strategies better left for curious experimenters.

Components Needed

  • form.events to capture value/status/touched/pristine notifications
  • Foundational RXJS: (pipe/map/startWith/combineLatest)
  • Core component: bridging RXJS & Signals with toSignal()

Kick off by crafting matching helper functions

We can sift through the events stream to isolate ValueChangeEvent instances.

import { AbstractControl, ControlEvent, ValueChangeEvent } from '@angular/forms';

function valueEvents<T>(form: AbstractControl<T>): Observable<ValueChangeEvent<T>>  {
  return form.events.pipe(
    filter(
      (event: ControlEvent): event is ValueChangeEvent<typeof form.value> =>
        event instanceof ValueChangeEvent,
    ),
  );
}

To keep things concise, the identical logic can be applied to StatusChangeEvent. Furthermore, this extended event object now provides both TouchedChangeEvent and PristineChangeEvent. Meanwhile, all four event varieties return their corresponding data types in a synchronous manner—for example, touched: boolean arrives with TouchedChangeEvent.

Next, there is another category of utility functions: the isType helpers. Because the streams will emit a mix of event types, such as StatusChangeEvent, and we need to extract that event's status—while also relying on startWith(form.status)—we require these type guards to distinguish the diverse stream values during mapping. Within our combination function's body, the usage looks like this:

function isStatusEvent<T>(event: ControlEvent | T): event is StatusChangeEvent {
  return event instanceof StatusChangeEvent;
}

map(([valueParam, statusParam, touchedParam, pristineParam]) => {
  ...
  // This can be turned into a ternary
  let stat: FormControlStatus | StatusChangeEvent;
  if (isStatusEvent(statusParam)) {
    stat = statusParam.status;
  } else {
    stat = statusParam;
  }
  ...
})

The complete implementation for every isType and typeEvents helper, covering all four event categories, is provided via a link at the conclusion.

There is one more aspect I deliberately postponed addressing. If any value or other form data is updated within a lifecycle hook, say OnInit, that mutation occurs after the initial startWith operators have already fired. Consequently, those form data modifications would go unnoticed and fail to appear in the form's original subscription snapshot. Fortunately, as detailed in the article "Angular FormGroup valueChanges: Deferring Observable", wrapping the entire process in an RXJS defer operator solves this problem cleanly.

With all components and utility functions assembled, here is the ultimate recipe:

export function allEventsObservable<T>(
  form: AbstractControl<T>,
): Observable<FormEventData<T>> {
  return defer(() => combineLatest([
    valueEvents$(form).pipe(
      startWith(form.value),
      map((value) => (isValueEvent(value) ? value.value : value)),
      distinctUntilChanged(
        (previous, current) =>
          JSON.stringify(previous) === JSON.stringify(current),
      ),
    ),
    statusEvents$(form).pipe(startWith(form.status)),
    touchedEvents$(form).pipe(startWith(form.touched)),
    pristineEvents$(form).pipe(startWith(form.pristine)),
  ]).pipe(
    map(([valueParam, statusParam, touchedParam, pristineParam]) => {
      // Original values (plus value)
      const stat: FormControlStatus | StatusChangeEvent = isStatusEvent(statusParam)
        ? statusParam.status
        : statusParam;
      const touch: boolean | TouchedChangeEvent = isTouchedEvent(touchedParam)
        ? touchedParam.touched
        : touchedParam;
      const prist: boolean | PristineChangeEvent = isPristineEvent(pristineParam)
        ? pristineParam.pristine
        : pristineParam;

      // Derived values - not directly named as events, 
      //     but are aliases for something that can be derived from original values
      const validDerived = stat === 'VALID';
      const invalidDerived = stat === 'INVALID';
      const pendingDerived = stat === 'PENDING';
      const dirtyDerived = !prist;
      const untouchedDerived = !touch;

      return {
        value: valueParam,
        status: stat,
        touched: touch,
        pristine: prist,
        valid: validDerived,
        invalid: invalidDerived,
        pending: pendingDerived,
        dirty: dirtyDerived,
        untouched: untouchedDerived,
      };
    }),
  ));
}
export function allEventsSignal<T>(
  form: AbstractControl<T>,
): Signal<FormEventData<T>> {
  return toSignal(allEventsObservable(form), {
    initialValue: {
      value: form.value,
      status: form.status,
      pristine: form.pristine,
      touched: form.touched,
      valid: form.valid,
      invalid: form.invalid,
      pending: form.pending,
      dirty: form.dirty,
      untouched: form.untouched,
    },
  });
}

This is how we use it in the component:

  fb = inject(NonNullableFormBuilder);
  form = this.fb.group({
    firstName: this.fb.control('', Validators.required),
    lastName: this.fb.control(''),
  });

  formEventsAsObservable = allEventsObservable(this.form);
  formEventsAsSignal = allEventsSignal(this.form);
Form Events util in action with a form and the util object as JSON
Form Events util in action with a form and the util object as JSON

Summary

Gathering the proper components, a signals-based approach to forms has been developing since v16 and saw further improvements in v18. While no official merge of reactive forms with signals exists yet, this approach represents the best available option. Above all, I hope that even if you never need to utilize these bootleg reactive form signals, you've gained insights into forms, signals, observables, and the tool that bridges observables with signals.

I owe thanks to Erick Rodriguez, Alain Boudard, and Matthieu Riegler for their peer reviews, as well as to Angular Spaces for providing this platform.

  • Stackblitz demo showcasing both v16 and v18 implementations: https://stackblitz.com/edit/stackblitz-starters-masfsq?file=src%2Fform-events-utils.ts
    • v18 output is displayed directly within the HTML
    • v16 output appears in the browser console
  • Repository containing complete utility source and demos
    • Example home page. Execute ng serve and open http://localhost:4200/
    • v16 utilities
      • The demo repo relies on v18, but the code within this utility file can be transferred to a v16 environment, or even older solely for the observable stream
      • Monitor the console during execution to observe how those events are triggered
    • v18 utilities
      • The template displays all four event value categories

Brewing Bootleg Reactive Forms Signals from RXJS — figure 3

Last Update: June 20, 2024