While developing Angular applications with Signals, I frequently encounter the need for practical utilities not yet supported by the framework. This prototype addresses specific challenges when working with effect()
.
Wait for the next change
When working with signals, I sometimes need to delay a function's execution until the next signal change. However, using effect()
presents two key problems in this scenario.
- The first
effect()
execution uses the current signal value. - An
effect()
cannot be destroyed from within its own callback function.
As a result, here's an example implementation of waitForNextChange()
. This function returns a Promise<T>
that resolves with the next value from the source Signal<T>
. The function skips the first (current) value, uses the second value to resolve the promise, and when the promise resolves, the watcher will be destroyed.
function waitForNextChange<T>(
source: Signal<T>,
injector: Injector
): Promise<T> {
let watcher: EffectRef;
const p = new Promise<T>((resolve) => {
let first = true;
watcher = effect(
() => {
const value = source();
if (first) {
// skip the first value
first = false;
} else {
untracked(() => resolve(value));
}
},
{ injector, manualCleanup: true }
);
});
return p.finally(() => watcher.destroy());
}
const touched = signal(false);
waitForNextChange(touched, injector).then(() => {
console.log('touched');
});
The implementation above is virtually identical to this:
// Rewrite with rxjs-interop
function waitForNextChange<T>(
source: Signal<T>,
injector: Injector
): Promise<T> {
return firstValueFrom(toObservable(source, { injector }).pipe(skip(1)));
}
Alternatively, using a callback function style similar to Angular's afterNextRender
, the implementation would look like this:
function afterNextChange<T>(
source: Signal<T>,
injector: Injector,
callback: (value: T) => void
): void {
let watcher: EffectRef;
const p = new Promise<T>((resolve) => {
let first = true;
watcher = effect(
() => {
const value = source();
if (first) {
// skip the first value
first = false;
} else {
untracked(() => resolve(value));
}
},
{ injector, manualCleanup: true }
);
});
p.then(callback).finally(() => watcher.destroy());
}
const touched = signal(false);
afterNextChange(touched, injector, () => {
console.log('touched');
});
Considerations
Although these implementations are functional, there are some drawbacks to consider. The requirement for an Injector
particularly degrades the developer experience. While Promises are a native JavaScript feature and Signals are meant to be Angular's "primitives," converting between them is unexpectedly complex. This conversion process should be more intuitive and require less boilerplate.