Why wait for a formal reactive forms and signals integration when you can brew one up yourself? If you take a recipe handed down by one of the finest brewers in the reactive form space (Joshua Morony) and throw in some extra ingredients of you own, you can utilize signals with reactive forms today. With some simple RxJS to signal interop methods, you can react to value and status state in reactive forms using signals as early as Angular 16! And with a new API for form events introduced in Angular 18, this future facing approach will only get better with age.

A Sample of the End Product

Should you follow the recipe right, a function with this signature is the end product.

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

Familiar Ingredients And Process

This wouldn't be a recipe from the internet if there wasn't some backstory given. BUT IT'S IMPORTANT I SWEAR! Some context on how this was done in the old days will give insight into this new API for reacting to form events.

The inspiration for this recipe? A section of the video "SIGNALS can make Angular "REACTIVE" forms more reactive" by Joshua Morony, a master brewer of observability himself. The whole approach of the video extends beyond the scope of this article, but they key helper function lies within at 3:31, with the function 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 recipe has proven to be timeless, even with new ingredients possible in Angular 18. From the idea of making utilities to react to form changes with form.valueChanges and form.statusChanges, we can brew form signals. But before we get started, we need to tweak the base a bit.

An essential part of these utilities is that not only can they grab the value and status as they change, but also have initial values. Josh's full approach has an initial form state done his way, but with our recipe we can always ensure the value stream has a value with the RXJS operator startWith. And as a bonus, we will throw in a distinctUntilChanged to help reduce some redundant emissions from the value stream, and a generic T for a stronger type.

Full Recipe: Retrieving value/status/touched/pristine as a Signal and Observable using a new Angular 18 API

The pull request that made it into Angular 18 called "Unified Control State Change Events #54579" introduced an observable on forms called events which exposes a stream of events and their respective values. For a detailed overview of how it works, check out this incredible short video by Igor Sedov, "New in Angular 18: Unified Control State Change Events for Forms".

Events (which extend ControlEvent)

  • ValueChangeEvent
  • StatusChangeEvent
  • TouchedChangeEvent
  • PristineChangeEvent
  • FormResetEvent (no value accessor)
  • FormSubmittedEvent (no value accessor)

This recipe will not include helpers to get a form's reset and submitted as they do not have initial values. A recipe with those would have its own considerations that can be left to aspiring brewers.

Ingredients

  • form.events for value/status/touched/pristine events
  • Basic RXJS: (pipe/map/startWith/combineLatest)
  • Key ingredient: RXJS & Signal interop: toSignal()

First, let's make some similar looking helper functions

We can filter for ValueChangeEvent instances from the events stream.

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,
    ),
  );
}

For brevity, the same can be done for StatusChangeEvent. Additionally, this new event object also exposes TouchedChangeEvent and PristineChangeEvent. And among these four event types, they also return their respective types of values synchronously, such as touched: boolean for TouchedChangeEvent.

One more type of helper functions: isType functions. Since the streams will return various events like StatusChangeEvent and we want that event's status, but we also use startWith(form.status), we will want type helpers to differentiate the stream values we map out. Inside the body of our combination function, we will use them 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 full code for all of the isType and typeEvents for the four types of events will be linked to at the end.

Oh, and one more thing I have... put off until now. Put another way, I should no longer defer explaining one thing. If the form had its value or other data changed in a lifecycle hook like OnInit, then that happens after the startWith usages. So those form data setters would be missed, and not reflected in the form's initial subscription state. Luckily, as described in "Angular FormGroup valueChanges: Deferring Observable", we can just wrap this whole thing in an RXJS defer and call it a day!

With all of the ingredients and helper functions, let's look at the final 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

Summary

With the right ingredients, a signals API for forms has been brewing since v16 and got even better in v18. Until a formal integration of reactive forms and signals is introduced, this recipe is about as good as it gets. And I hope above all, even if you don't need to bootleg reactive form signals in the future, you have learned something about forms, signals, observables, and the observable & signal interoperability package.

Thank you Erick Rodriguez, Alain Boudard, and Matthieu Riegler for peer reviews, and Angular Spaces for the opportunity.


Last Update: June 20, 2024