Angular Forms: advanced debounce
We all have done a “debounced search form” at some point in our life. Given a text input, when the user types something into it, then after a (small) amount of time the form value is sent as the search request to the backend to fetch some results. If the user changes the input the ongoing request is cancelled (if it’s still on the fly ) and a new one is started.
It’s the famous combination of debounceTime
and switchMap
on the AbstractControl.valueChanges
we all know and love.
But there’s one thing which bothers me on such search forms. If you not only have a text input but some additional options like checkboxes or selects, than changes on one of those are also debounced. Or I would really like to “Enter” after I finished typing my search term to trigger the request without waiting for the debounce to happen.
Lucky for us rxjs has us covered with the debounce
operator.
And that combined with the new AbstractControl.events
provides the right tools to improve our users’ experience.
The Simple Implementation
There are two interesting events:
ValueChangeEvent
FormSubmittedEvent
(I already used that in the “declarative submit” post)
Both are emitted from the events
observable, so we should be able to debounce only on ValueChangeEvent
s and let the FormSubmittedEvent
just pass without debounce.
For these kind of usecases the debounce
operator accepts a function, which returns an observable depending on the current value of the source observable.
If we want to debounce, we have to return an observable which emits in the future, e.g. with the timer
function.
If we don’t want do debounce, we can return an observable which emits synchronously like of
.
The first implementation could be something like this:
const debounceMs = 500;
form.events.pipe(
filter(
(event) =>
event instanceof ValueChangeEvent || event instanceof FormSubmittedEvent
),
debounce((event) =>
event instanceof FormSubmittedEvent ? of(0) : timer(debounceMs)
),
map(() => form.getRawValue()),
switchMap((query) => service.search(query)),
// etc.
);
And it will totally do the job. Of course you have to add some error handling on the service call. And of course we don’t want to emit invalid queries. And what should happen, if the user modifies the input to an invalid state - should the old results be visible or cleared?
Time for…
The Advanced Implementation
(aka over-engineering 😎)
We want:
- filter for
ValueChangeEvent
andFormSubmittedEvent
, - map that to a “value with reason” which can be one of
DEBOUNCE
,SUBMIT
orNOT_VALID
,
(If the source of theValueChangeEvent
is not a “control to be debounced” the reason also will be mapped toSUBMIT
. The “controls to be debounced” will be provided as a list of control paths likeAbstractControl.get()
would accept.) - debounce the emission on the reason
DEBOUNCE
, - map the reason
NOT_VALID
tonull
otherwise pass the form value, - since all
NOT_VALID
s are mapped tonull
we add adistinctUntilChanged
, - and because we don’t know how many subscribers there will be, we
share
the observable.
After that we can feed these values into something which handles async service calls.
Done! Here’s the code on Stackblitz.
(There’s also some simple paging involved - but that’s not important for the debounce part of this example.)
The Details
Beside the FormValueOf
helper type we need some more helpers.
ValueSource
A “source of values” can either be just a value, a signal or an observable. All these can be converted into an observable. This enables a more reactive approach, so we can not only start from a given form but also from signals or observables containing forms. If we want to be “reactive from end to end” we should enable the users of our utilities to chain them into whatever reactive stream they have.
import { Injector, Signal, isSignal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { Observable, isObservable, of } from 'rxjs';
export type ValueSource<TValue> = TValue | Signal<TValue> | Observable<TValue>;
export type ValueSourceOptions = Readonly<{
injector?: Injector;
}>;
/**
* Converts any `ValueSource` into an observable.
* Usefull to create observable streams from any value source.
*/
export const sourceToObservable = <TValue>(
source: ValueSource<TValue>,
options?: ValueSourceOptions
): Observable<TValue> =>
isObservable(source)
? source
: isSignal(source)
? toObservable(source, { injector: options?.injector })
: of(source);
getControlPathFromRoot
When we process a ValueChangeEvent
the source
property reflects which control triggered this event.
This can be any control nested deep into the whole FormGroup
.
Angular doesn’t provide a function (correct me if I’m wrong), which returns the path from the root form up to the given control.
It’s not that difficult - we just have to search through the controls of the parent control until we find it and get the name.
And do that up to the root control (recursion!).
And then we join all those names separated with a dot.
Ah - don’t forget about FormArray
s - their controls
is an array with numbers as keys.
import { AbstractControl } from '@angular/forms';
type ControlName = string | number;
type ControlPath = readonly ControlName[];
const EmptyControlPath: ControlPath = [];
const controlPathToFormPath = (path: ControlPath): string => {
return path == null || path.length === 0 ? '' : path.join('.');
};
const getControlName = (control: AbstractControl): ControlName | null => {
if (control.parent == null) {
return null;
}
const children = control.parent.controls;
if (Array.isArray(children)) {
for (let index = 0; index < children.length; index++) {
if (children[index] === control) {
return index;
}
}
return null;
}
return (
Object.keys(children).find((name) => control === children[name]) ?? null
);
};
const getControlPath = (control: AbstractControl | null): ControlPath => {
let path: ControlPath = EmptyControlPath;
let current: AbstractControl | null = control;
while (current != null) {
const name = getControlName(current);
if (name == null) {
break;
}
path = [name, ...path];
current = current.parent;
}
return path;
};
/**
* Returns the path of the control from the root to itself.
* Can be used by `.get(...)` on the root.
*/
export const getControlPathFromRoot = (
control: AbstractControl | null
): string => {
return controlPathToFormPath(getControlPath(control));
};
(I told you we over-engineer!)
debounceForm
And finally - the code!
import {
AbstractControl,
ControlEvent,
FormSubmittedEvent,
ValueChangeEvent,
} from '@angular/forms';
import {
Observable,
debounce,
distinctUntilChanged,
filter,
map,
of,
share,
switchMap,
timer,
} from 'rxjs';
import { FormValueOf } from './form-value-of';
import { getControlPathFromRoot } from './get-control-path-from-root';
import { sourceToObservable, ValueSource } from './value-source';
type ValueAndReason<TValue> = Readonly<{
value: TValue;
reason: 'NOT_VALID' | 'DEBOUNCE' | 'SUBMIT';
}>;
const filterForValueChangeAndSubmittedEvents = () =>
filter(
(event: ControlEvent) =>
event instanceof ValueChangeEvent || event instanceof FormSubmittedEvent
);
const mapToValueAndReason = <TControl extends AbstractControl>(
form: TControl,
debounceOnSet: ReadonlySet<string>
) =>
map(
(
event: ValueChangeEvent<FormValueOf<TControl>> | FormSubmittedEvent
): ValueAndReason<FormValueOf<TControl>> => ({
value: form.getRawValue(),
reason:
form.status !== 'VALID'
? 'NOT_VALID'
: debounceOnSet.has(getControlPathFromRoot(event.source))
? 'DEBOUNCE'
: 'SUBMIT',
})
);
type DebounceIfPredicate<T> = (value: T) => boolean;
const debounceIf = <T>(predicate: DebounceIfPredicate<T>, debounceMs: number) =>
debounce((value: T) => (predicate(value) ? timer(debounceMs) : of(0)));
const mapToValidValueOrNull = <TValue>() =>
map(({ reason, value }: ValueAndReason<TValue>) =>
reason === 'NOT_VALID' ? null : value
);
/**
* Creates an observable which emits the values of the form
* whenever it changes or is submitted.
*
* It emits `null` if the status of the whole form is not `VALID`.
*
* When the name of the control, which is responsible for the `ValueChangeEvent`,
* is included in the `debounceOn` array, the emission of the form value will be
* debounced by the time given in `debounceMs`.
*
* The `debounceOn` array can include dot separated paths to nested controls
* like in `AbstractControl.get('nested.property')`.
*/
export const debounceForm = <TControl extends AbstractControl>(
$form$: ValueSource<TControl>,
debounceMs: number,
...debounceOn: string[]
): Observable<FormValueOf<TControl> | null> => {
const debounceOnSet = new Set<string>(debounceOn);
return sourceToObservable($form$).pipe(
switchMap((form) =>
form.events.pipe(
filterForValueChangeAndSubmittedEvents(),
mapToValueAndReason(form, debounceOnSet),
debounceIf(({ reason }) => reason === 'DEBOUNCE', debounceMs),
mapToValidValueOrNull(),
distinctUntilChanged()
)
),
share()
);
};
That was fun! 🥰