Learn When to use Signal Effects in Angular and Why You Should Avoid Overusing them 🚫
Angular effects are very powerful and flexible, but they also have a lot of trade-offs that you might not realize you're making when you use them.
Understanding Signal Effects
Before diving into why we should be cautious with effects, let's briefly recap what they are. Signal effects
in Angular are functions that automatically track their dependencies and re-execute when those dependencies change. They're primarily designed for synchronizing the reactive world of signals with the non-reactive world, such as manipulating the DOM or interfacing with external APIs.
You can learn more about how Angular effects works here:
count = signal(1);
effect(() => {
console.log(`The current count is: ${count()}`);
});
While this seems straightforward, the simplicity can be deceptive. Let's explore why.
The Case Against Overusing Effects
1. Complexity and Hidden Trade-offs
Effects are powerful and flexible, but they come with trade-offs that might not be immediately apparent. Alex Rickabaugh, a core Angular team member, points out:
"Effects are very powerful and flexible, but they also have a lot of trade-offs that you might not realize you're making when you use them."
This creates a disconnect between the apparent simplicity of effects and the potential complexity they introduce to your application.
2. Synchronization Issues
One of the primary issues with effects is that they're often used to synchronize different pieces of state. This can lead to race conditions and inconsistent state.
Consider this example:
// Code from Alex Rickabaugh (from video)
@Component({
selector: 'app-options',
standalone: true,
imports: [/*...*/],
template: `
<ul>
@for (option of options(); track option) {
<li (click)="select($index)">{{ option }}</li>
}
</ul>
`
})
export class SelectComponent {
// options coming from outside
options = input<string[]>();
/**
* How do we clear the selectedIndex when options change?
* They probably use an effect() as described in the constructor()
*/
selectedIndex = signal(-1);
select(idx: number): void {
this.selectedIndex.set(idx);
}
constructor() {
// Don't do this ❌
effect(() => {
// Reset selectedIndex when options change
this.options();
this.selectedIndex.set(-1);
}, { allowSignalWrites: true });
}
}
At first glance, this might seem like a good way to reset the selected index when the options change. However, this approach has several issues:
It treats
options
andselectedIndex
as independent pieces of state when they're inherently related.It can lead to a "glitch" where the UI momentarily displays an invalid state.
It's not immediately clear to other developers why this effect exists or what its full implications are.
You need to clarify to Angular's reactive system that options
and selectedIndex
are not two independent sources of truth. Instead, you should establish a more appropriate relationship between them to have a single source of truth:
// The selectedIndex value only has significance when associated with a
// particular set of options.
@Component({/*...*/})
export class SelectComponent {
options = input<string[]>();
state = computed(() => {
return {
options: this.options(),
// ✅ We create a WritableSignal inside the computed()
selectedIndex: signal(-1)
};
});
select(idx: number): void {
this.state().selectedIndex.set(idx);
}
// no more effect()
}
In the solution, we create a WritableSignal
within the computed()
function. This approach means that whenever options()
changes inside our computed()
function, we discard the previous concept of selectedIndex
and replace it with an entirely new state container (WritableSignal
). This new WritableSignal
is initialized with no selection, effectively resetting the selected state.
Imagine now this case:
name = input('');
setName(name: string): void {
this.name.set(name); // But, we don't have set method here: ❌ ERROR
}
So the solution to this is to use computed()
again:
// Of course you can use here the model() because has a set method, but
// sometimes you don't want that
name = input('');
myName = computed(() => signal(this.name())); // ✅
setName(name: string): void {
this.myName().set(name);
}
With this solution, when the parent component changes with a new name, it will overrides the old signal
from computed
and brings us a new one. This throws away the previous state and brings us a new one.
3. Timing Issues and "Glitches"
Effects don't run immediately when their dependencies change. Instead, they run on a schedule, which can lead to what's known as "glitches" - temporary inconsistencies in your application state.
Alex Rickabaugh explains:
"Effects aren't immediate things, right? They run on a schedule at some point in the future... so there's a period of time where the options have changed but the index is still showing the old value because the effect hasn't had a chance to synchronize them yet."
This timing issue can be particularly problematic in complex applications where multiple effects might be chaining off each other.
4. Violation of Single Source of Truth
Using effects to synchronize different pieces of state often violates the principle of having a single source of truth. This can make your application harder to reason about and maintain.
5. Potential for Infinite Loops
If not carefully managed, effects can create infinite loops. Consider this example:
count = signal(0);
effect(() => {
console.log(`Count is: ${count()}`);
count.update(v => v + 1); // This will trigger the effect again
}, { allowSignalWrites });
This effect will run indefinitely, continuously incrementing the count.
When to Use Effects
Despite these cautions, effects do have their place. They're primarily useful for synchronizing between the reactive world (signals, computed values) and the non-reactive world. Here are some legitimate use cases:
1. DOM Manipulation
When you need to perform direct DOM manipulation that can't be achieved through Angular's template syntax:
effect(() => {
const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, width(), height());
});
2. Integration with Third-party Libraries
When working with libraries that aren't natively reactive:
effect(() => {
chart.setData(chartData());
chart.redraw();
});
3. Logging and Analytics
For tracking state changes without affecting the application's behavior:
effect(() => {
analyticsService.trackEvent('State Changed', {
user: currentUser(),
page: currentPage()
});
});
Better Alternatives to Effects
In many cases where you might be tempted to use an effect, there are better alternatives. Let's explore some of these.
1. Computed Signals
Instead of using effects
to derive state, use computed signals
. Check the example on the Synchronization Issues block.
2. Reactive Helpers
Libraries like ngxtension provide reactive helpers that can replace many common use cases for effects
. For example, derivedAsync
can be used for asynchronous computations:
// example from ngxtension web
export class MovieCard {
movieId = input.required<string>();
movie = derivedAsync(() =>
fetch(`https://localhost/api/movies/${this.movieId()}`).then((r) =>
r.json(),
),
);
}
Or you can use rxMethod
in NGRX Signal Store as Manfred Steyer discussed in his post.
Connect from ngxtension library
connect()
is a utility function that connects a signal to an observable or another signal, automatically updating the signal's value when the source changes.
Let’s look at some scenarios where connect()
can replace effect()
:
@Component({...})
export class MyComponent {
private dataService = inject(DataService);
pageNumber = signal(1);
constructor() {
connect(this.pageNumber, this.dataService.pageNumber$);
}
}
3. RxJS for Complex Asynchronous Logic
For complex asynchronous operations or when you need to manage multiple streams of data, RxJS is often a better choice than effects.
Rendering vs Business Effects (Automatic vs. Explicit Tracking)
The Current Landscape
Angular's effect()
function is primarily designed for "rendering effects" - side effects that are closely tied to the UI and component rendering. This approach uses automatic tracking, where the framework determines dependencies and when to re-run the effect. While this works well for many scenarios, it has led to some challenges in certain use cases, particularly when dealing with business effects
.
The GitHub Discussion
A recent GitHub discussion in the Angular repository highlighted these challenges. Developers working on refactoring applications to use Angular's Signals observed a pattern where untracked()
was frequently necessary to prevent unintended side effects. This raised concerns about:
The frequent use of
untracked()
The error-prone nature of forgetting to use
untracked()
The added maintenance overhead
The Two Types of Effects
Alex Rickabaugh, a core member of the Angular team, provided insight into this issue by distinguishing between two types of effects:
Rendering Effects: These are optimized for UI-related tasks, such as conditionally rendering elements or manipulating the DOM directly.
Business Effects: These handle application logic, like data fetching, event dispatching, or data synchronization between different sources.
The Case for Explicit Tracking
The GitHub discussion proposed a shift towards an explicit tracking model for effects. This would involve:
Explicitly declaring dependencies within effects
Making effects non-tracking by default
This approach aims to reduce errors, improve the developer experience, and potentially enhance performance.
ngxtensions explicitEffect
In response to this need, the ngxtensions library introduced explicitEffect
. This function allows developers to create effects that only depend on explicitly provided signals. Here's an example:
import { explicitEffect } from 'ngxtension/explicit-effect';
const count = signal(0);
const state = signal('idle');
explicitEffect([count, state], ([countValue, stateValue]) => {
console.log(`Count: ${countValue}, State: ${stateValue}`);
});
explicitEffect
offers several benefits:
Clear dependency declaration
Prevention of unintended signal reads or writes
Optional cleanup function
Ability to defer the initial execution
Future Directions
While no definitive decisions have been made, the Angular team is actively considering these issues. Possible future developments could include:
Enhanced documentation about untracking and effect behavior.
New APIs or utility functions in
@angular/core
for more flexible effect management.Continued evolution of community-driven solutions.
Recent Changes to Effect Behavior
A recent significant change has been made to the execution timing of effects in Angular, as outlined in PR #57874. This change has important implications for developers using effects:
Removal of
allowSignalWrites
: TheallowSignalWrites
option for effects has been removed. This simplifies the API but may require adjustments in code that relied on this feature.Changes in Execution Timing:
Effects triggered outside of change detection now run as part of the change detection process instead of as a microtask. This can result in earlier or later execution depending on the specific application or test setup.
Effects triggered during change detection (e.g., by input signals) now run earlier, before the component's template is updated.
Impact on Testing:
Tests that relied on the Promise timing of effects may now need to use
await whenStable()
or call.detectChanges()
to ensure effects run.Tests using faked clocks might need to fast-forward or flush the clock to trigger effect execution.
Potential Breaking Changes:
Effects that previously relied on the application being fully rendered (e.g., reading computed styles) may now get incorrect results as they run before component updates.
Effects synchronizing with the forms system may need to adjust their initialization timing due to their sensitivity to execution order.
New API for Post-Render Effects: The
afterRenderEffect()
API is now recommended as a replacement for effects that need to run after the component has been fully rendered.
Implications for Developers
Code Review: Developers should review their use of effects, especially those that interact with the DOM or rely on specific execution timing.
Testing Updates: Test suites may need to be updated to account for the new execution timing of effects.
Migration to
afterRenderEffect()
: For scenarios where effects need to run after rendering, consider migrating to the newafterRenderEffect()
API.Careful Consideration of Effect Timing: When designing new features or refactoring existing ones, be mindful of when effects run in relation to change detection and component rendering.
Best Practices When Using Effects
If you do need to use an effect, here are some best practices to follow:
Keep effects small and focused: Each effect should do one thing and do it well.
Avoid writing to signals from effects: This can lead to circular dependencies and infinite loops.
Use
untracked
when appropriate: If part of your effect shouldn't trigger re-runs, wrap it inuntracked
.Consider using explicit dependencies: Libraries like ngxtension provide an
explicitEffect
that allows you to specify which signals the effect depends on explicitly.Document your effects: Since effects can have wide-ranging implications, it's important to document why they exist and what they're doing.
Roots vs View Effects
Think of effects in Angular like two different types of observers in a building:
Building-wide observers (root effects): These monitor changes from anywhere in the building (those created in a root service for example).
Room-specific observers (view effects): These only watch what happens in their assigned room (those created in components).
If you want to learn more about the differences between them you can read the post created by Matthieu Riegler.
Conclusion
Remember, as Alex Rickabaugh advises:
Try other solutions first, and if you have to come back to effect, then there's probably a reason for that, but it's often like avoid the temptation to reach for it before you think through the problem.
By approaching effects with caution and leveraging Angular's other reactive primitives, you can build more predictable, easier-to-maintain applications. Happy coding!
Thanks for reading so far 🙏
I’d like to have your feedback so please leave a comment, clap or follow. 👏
Spread the Angular love! 💜
If you really liked it, share it among your community, tech bros and whoever you want! 🚀👥
Don't forget to follow me and stay updated: 📱
Thanks for being part of this Angular journey! 👋😁