Understanding effects
Root vs View effects in v19
Matthieu Riegler -
Unlike other signals APIs, the effect
hasn't stabilized yet and is still in developer preview. This has a reason as scheduling and reactivity context need some fine tuning based on developer feedback.
Effects are a construct which executes a reactive function whenever the signals it reads are updated. They're declared via the effect()
function:
Effect definition
function effect(fn: (cleanupFn: CleanupFn) => void, options?: EffectOptions): EffectRef;
interface EffectOptions {
injector?: Injector;
forceRoot?: true;
manualCleanup?: boolean;
}
Context dependent effects
Effects can be classified into two types: root effects and view effects. The type created is determined by the context in which the effect()
function is called. If called within components, a view effect is generated; otherwise, a root effect is generated.
Note that, Angular handles for you which type of effect is creating depending on the calling context. There is no need to explicitly choose between view and root effects. This behavior can be overriden with the forceRoot
flag that enforces the creation of a root effect.
Root effects
Root effects are top-level effects within an application and belong to no hierachy and independent from component updates. Effects created in root services for example are root effects
They are useful for operations like:
- Propagating state changes to other signals (when computeds are not an option).
- Synchronizing state with the backend or some local storage
- Rendering not tied to a component (e.g. integration w/ other frameworks)
- Logging/Debugging
Root effects are scheduled via a macrotask, on each ApplicationRef.tick
(and while there are dirty root effects).
They are queued in FIFO order: effects that become dirty first will execute first.
One particular consequence of dirty root effects running until the queue is empty is that you might see effects running kind-of synchronously.
sig = signal(0);
#myRootEffect = effect(() => {
if (this.sig() < 5) {
console.log(this.sig());
this.sig.update((s) => s + 1);
}
});
This sample code will log 5 times before running any Change Detection.
Concerning unit test, you can flush the root effects with TestBed.flushEffects()
.
View effects
View effects are effects within the component hierarchy and execute as part of change detection cycles. Because of this timing, view effects can be used to respond to input signal changes, or to update state used in child components (including creating and destroying child views).
The need for view effects is driven by two major use cases / concerns which are not served by root effects:
- Signal inputs.
Input signals are set as part of the change detection process. This timing is important for effects that monitor them. More importantly, required signal inputs can't be read until they received their first value. This means that effects must not be scheduled until the component's inputs have been set.
Crucially, input.required
signals are not allowed to be read until they have received their initial value. This means that effects must not be scheduled until the component's inputs have been set.
- Effects that affect the component's state or the state of child components.
A major reason to create effects in components is to react to input changes and update component state, either by deriving new values for the component or its children, or by creating or destroying embedded views. For example, the following effect implements a reactive version of @if
control flow:
Effect that needs to run ahead of CD
const show = computed(() => !!cond());
let view;
effect(() => {
if (show()) {
view = this.vcr.createEmbeddedView(this.childView);
} else {
view.destroy();
}
});
Now imagine this effect runs after change detection. This would have 2 major consequences :
- If a child view is created, it would have to schedule a new Change detection cycle, which would be inefficient
- If the
cond
become false (and consequentlyshow
also), the embedded view would get change detected before being destroyed. This will result in a violation of the invariant that the effect is trying to enforce, and the embedded view will be change detected with a null value it was not written to handle.
In other words, effects that affect the rendering of their children must run before those children undergo change detection. Failing to do so can lead to broken invariants and crashes, as well as inefficiency. View effects ensure this guarantee. For this reasons view effect will run during change detection at the begining of each component check.
See this stackblitz example of this issue with v18 effect timing.
Scheduling
View effects are associated with a given node within a template. For example, given the template:
<div tooltipDirective>...</div>
<child-cmp childDirective />
The effects for tooltipDirective would execute when the update pass reached the <div>
node, and the effects for <child-cmp>
and childDirective would execute when the update pass reached the <child-cmp>
node. This is the same mechanism that directive lifecycle hooks and host bindings use today.
View effects should run before host bindings, as they might update state which is read in host bindings. This is a similar timing to ngOnChanges / ngDoCheck. Note that this requires the parent view of the associated node to be refreshed in order to trigger the effect. This is usually the case anyway (in the common case, an input signal from that view was what triggered the effect) but is sub-optimal if the signal triggering the effect had nothing to do with the parent view. We could resolve this inefficiency with some investment, but it may not be worth the complexity.
AfterRenderEffect
With view effects triggering before the components updates & change detection, we still need a reactive primitive that would be triggered once the application is fully rendered.
This is what AfterRenderEffect
is built for and it will execute registered effects during specific phases.
earlyRead
- Use this phase to read from the DOM before a subsequent
write
callback, for example to perform custom layout that the browser doesn't natively support. Prefer theread
phase if reading can wait until after the write phase. Never write to the DOM in this phase.
- Use this phase to read from the DOM before a subsequent
write
- Use this phase to write to the DOM. Never read from the DOM in this phase.
mixedReadWrite
- Use this phase to read from and write to the DOM simultaneously. Never use this phase if it is possible to divide the work among the other phases instead.
read
- Use this phase to read from the DOM. Never write to the DOM in this phase.
afterViewEffect
afterRenderEffect({
earlyRead: () => ...,
write: () => ...,
mixedReadWrite: . ..,
read: () => ...,
});
Error handling & Testing
As both type of effects are run as part of change detection, they report both to the change detection ErrorHandler
.
This means that an error happening inside an effect will thrown at the top, namely on ApplicationRef.tick()
.
Here is an example that checks for errors during the execution of an effect.
it('should throw error...', () => {
// create an effect that throws
const appRef = TestBed.inject(ApplicationRef);
effect(
() => {
throw new Error('fail!');
},
{injector: appRef.injector},
);
// explicitly run the CD and check for the thrown exception
expect(() => appRef.tick()).toThrowError('fail!');
});
In the case of more complex error handling, we can resort to listening to the ErrorHandler
itself.
Because an exception that happen during an effect execution will bubble up to the change detection process, it is important to disable that
behavior in the tests by setting rethrowApplicationErrors: false,
(or else tick()
throws)
Here is our example updated, where the exception is checked via the ErrorHandler
it('should throw error...', () => {
let lastError: any = null;
class FakeErrorHandler extends ErrorHandler {
override handleError(error: any): void {
lastError = error;
}
}
TestBed.configureTestingModule({
providers: [{provide: ErrorHandler, useFactory: () => new FakeErrorHandler()}],
// we make sure to prevent tick() from throwing.
rethrowApplicationErrors: false,
});
// create an effect that throws
const appRef = TestBed.inject(ApplicationRef);
effect(
() => {
throw new Error('fail!');
},
{injector: appRef.injector},
);
// explictly run the CD
appRef.tick();
// check for errors that were reported
expect(lastError.message).toBe('fail!');
});
Last word
As mentionned in the Angular blog post on effect
,
because of the changes around the timing, effect
will remain in developer preview to gather additional feedback.
Feel free to report issues you might encounter with those changes.