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.
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);
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.
Links
- Stackblitz example with working v16 and v18 examples: https://stackblitz.com/edit/stackblitz-starters-masfsq?file=src%2Fform-events-utils.ts
- v18 output shown inline in the HTML
- v16 output logged to console
- Repo with full utility code and examples
- Home page of example. Run
ng serve
and navigate tohttp://localhost:4200/
- v16 utils
- The example repo uses v18, but the exact code from this util file can be extracted to a v16 repo, or even earlier for just the observable stream
- Check the console while running to see how those events are fired off
- v18 utils
- All four types of event values are shown in the template
- Home page of example. Run