Angular's Signals are designed with simplicity in mind, providing three core functions: signal()
to create Signals,computed()
for derived Signals, and effect()
to handle side effects.
The last one, effect()
, stands out. While still in developer preview (unlike signal()
and computed()
, which became stable in v17), effect()
has garnered a lot of attention in social media, blog posts, and community discussions. Many suggest avoiding it altogether, with some even saying it shouldn't exist.
This article presents three key arguments:
effect()
has its righteous place and should be used where necessary.- The asynchronous nature of
effect()
means it should not be used to update Signals synchronously. For these cases,computed()
is the better choice, even if some edge cases lead to less readable code. - The community discussion should move beyond stylistic debates (such as declarative vs. imperative programming) and blanket statements like "Don't use
effect()
." Instead, the focus should be on understanding the real consequences of its asynchronous behavior, which can have significant impacts on applications.
If you prefer watching a video over reading: Video Version
- Signals Primer
- computed() or effect(): A Matter of Style?
- The Case for effect()
- effect()'s Achilles' Heel: Enforced Asynchrony
- The Reset Pattern
- Summary
Signals Primer
If you're already familiar with Signals, feel free to skip this section.
A Signal is a container for a value. To create one, we use the signal()
function. To read the value, we call the Signal like a function. To update the value, we use the set()
or update()
methods.
const n = signal(2);
console.log(n()); // 2
n.set(3);
console.log(n()); // 3
n.update((value) => value + 1);
console.log(n()); // 4
Updates to a Signal must be immutable. If the Signal holds an object, the new value must have a different object address, e.g., by creating a shallow clone.
computed()
creates a derived value, meaning it depends on other Signals and recalculates its value whenever those Signals change.
Signals created with signal()
are of type WritableSignal
, while those created with computed()
are of type Signal
.
A signal that notifies another is called a producer, while the one that depends on it is called a consumer.
const n = signal(2);
const double = computed(() => n() * 2);
console.log(double()); // 4
n.set(3);
console.log(double()); // 6
If we just want to execute code when one or more Signals change, we use the effect()
function. Its usage is similar to computed()
but doesn't return a new Signal.
An effect()
runs asynchronously, at least once initially, and then whenever its producer notifies it.
const n = signal(2);
effect(() => console.log(n()));
window.setTimeout(() => {
n.set(3);
n.set(4);
}, 0);
// console output 2: (asynchronous execution of effect)
// console output 4: (asynchronous execution of effect)
Note that there's no output for the value 3. This happens because multiple synchronous changes happened, and the
effect()
only captures the final state after the synchronous execution completes.
Be aware of implicit tracking: if your effect()
calls a method or function, all Signals used within it will be automatically tracked.
To avoid that, wrap that code with untracked
.
effect(() => {
const value = someSignalWeWantToTrack();
untracked(() => {
someService.doSomething(value);
});
})
For more information, head to the official Angular documentation.
computed() or effect(): A Matter of Style?
There's a strong tendency to caution against using effect()
. On social media, some even argue that you should never use it, though this doesn't hold up in real-world scenarios.
The official Angular documentation states: "Avoid using effects for the propagation of state changes."
The examples often presented are simple and are often cases where computed()
could easily replace effect()
. I'd argue that this is obvious.
Years of Angular + RxJS development have ingrained in us that this is an anti-pattern:
@Component({
// ...
template: `Double: {{ double }}`,
})
class DoubleComponent {
n$ = new BehaviorSubject(2);
double = 0;
constructor() {
this.n$.subscribe((value) => (this.double = value * 2));
}
}
The computation of double
here is a side effect. The best practice is to create derived Observable
streams and avoid direct subscriptions.
@Component({
// ...
template: `Double: {{ double$ | async }}`,
})
class DoubleComponent {
n$ = new BehaviorSubject(2);
double$ = this.n$.pipe(map((value) => value * 2));
}
This code is more declarative. We don't need to explicitly subscribe, calculate, and assign the value. Instead, we define the calculation and connect it to the source. This approach makes the code easier to read and maintain.
This example also presents a problem when the component uses OnPush
as change detection strategy. OnPush
is a performance optimization where components need to be explicitly marked as dirty in order to be checked and therefore updated in the DOM. While the async
pipe handles this automatically,
a manual subscription does not.
The same principle applies to effect()
and computed()
. The effect()
is imperative, while computed()
is declarative.
@Component({
// ...
template: `Double: {{ double }}`,
})
class DoubleComponent {
n = signal(2);
double = 0;
constructor() {
effect(() => (this.double = this.n() * 2));
}
}
Why would we use effect()
here if computed()
is available?
@Component({
// ...
template: `Double: {{ double() }}`,
})
class DoubleComponent {
n = signal(2);
double = computed(() => this.n() * 2);
}
Most of us wouldn't even consider using an effect()
in this scenario.
Many discussions focus too much on the imperative vs. declarative. This is a stylistic debate, as using effect()
in these cases doesn't harm your application.
Therefore, it can lead developers to think it's "safe" to use effect()
to update other Signals. In some cases, effect()
is even more readable.
The real issue is the asynchronous nature of the effect()
, which can cause significant bugs. An example will follow later, but before diving into those examples, let's first take a look at the valid use cases for effect()
.
The Case for effect()
The "Don't use effect()
" trend has led to some confusion. Whereas some developers might not see the risk of effect()
others may avoid effect()
even when it's the best and most appropriate choice.
Here are some noteworthy tweets from highly respected members of the Angular community:
- https://x.com/tomastrajan/status/1835596353143021850
- https://x.com/brandontroberts/status/1836815585776160805
- https://x.com/Nartc1410/status/1836066122904244443
The most common uses cases for effect()
are:
- "Signal-exclusive" side effects: When reacting to a Signal's change where the immediate outcome is not a derived Signal.
- asynchronous changes to Signals: When
effect()
updates another Signal, but first has to fetch data from a server.
Examples for side effects
A common example of using effect()
is logging a Signal change or synchronizing data with local storage:
@Component({
// ...
})
class DoubleComponent {
n = signal(2);
private logEffect = effect(() => console.log(n()));
private storageSyncEffect = effect(() => localStorage.setItem("n", JSON.stringify({value: n()})));
}
When interacting directly with the DOM, effect()
is also the right tool. For example, connecting a Signal to chart data:
export class ChartComponent {
chartData = input.required<number[]>();
chart: Chart | undefined;
updateEffect = effect(() => {
const data = this.chartData();
untracked(() => {
if (this.chart) {
this.chart.data.datasets[0].data = data;
this.chart.update();
}
})
});
// code for creating the chart
}
As of this writing, we know that Angular 19 will introduce subtle changes to effect
timing, which could impact the use of effect when dealing with DOM access. Additionally, new utility functions are on the way, making it easier to handle such scenarios.
Another common example is form synchronization:
export class CustomerComponent {
customer = input.required<Customer>();
formUpdater = effect(() => {
this.formGroup.setValue(this.customer());
});
formGroup = inject(NonNullableFormBuilder).group({
id: [0],
firstname: ["", [Validators.required]],
name: ["", [Validators.required]],
country: ["", [Validators.required]],
birthdate: ["", [Validators.required]],
});
}
As you can see, the recurring pattern is that all these examples react to Signal changes but don't update other Signals.
This is similar to how we use Observable
, where we had side effects in subscribe()
or the tap()
operator:
export class ChartComponent {
chartData$ = inject(ChartDataService).getChartData();
chart: Chart | undefined;
constructor() {
this.chartData$
.pipe(
tap((data) => {
if (this.chart) {
this.chart.data.datasets[0].data = data;
this.chart.update();
}
}),
takeUntilDestroyed(),
)
.subscribe();
}
// code for creating the chart
}
Examples with asynchronous Signal updates
Perhaps the most common use case for effect()
is when a Signal changes and you need to fetch data asynchronously before updating another Signal.
For instance, take the following example where a Signal
tracks a customer id
from route parameters. Based on this id
, you need to retrieve customer data from a server and update another Signal with the response:
@Component({
// ..
template: `
@if (customer(); as value) {
<app-customer [customer]="value" [showDeleteButton]="true" />
}
`
})
export class EditCustomerComponent {
id = input.required({transform: numberAttribute});
customer = signal<Customer | undefined>(undefined);
customerService = inject(CustomerService);
loadEffect = effect(() => {
const id = this.id();
untracked(() => {
this.customerService.byId(id).then(
(customer) => this.customer.set(customer)
);
})
});
}
In this example, loadEffect
listens for changes to the id
Signal, triggers an asynchronous fetch of customer data, and updates the customer
Signal once the data is available.
It may seem like loadEffect
is setting a derived value, but since there's an asynchronous task involved, computed()
isn't an option. computed()
requires the function to return a value immediately, which isn't possible here.
This loading mechanism could also be handled in a service, but that would just move the use of effect()
to another place.
If you have a large application where much data fetching depends on route parameters and you're already using effect()
for this purpose, it's perfectly fine.
Currently, your only options for reacting to Signal changes are computed()
and effect()
. If computed()
doesn't work, effect()
is the right choice.
It's worth mentioning that when it comes to asynchronous tasks, there’s always the elephant in the room: RxJS.
While RxJS can be a powerful tool for managing async workflows, this article focuses on the role of effect()
. I’ll touch on RxJS in more detail in another article.
If you want to go with an Observable
, you must first convert the Signal to an Observable
. For that, you'd use toObservable()
, but guess what? It uses an effect()
internally.
Let's just keep in mind that when it comes to managing asynchronous race conditions, there is no way around RxJS.
Before we continue, let's consider forcing our way to a computed()
. It's
possible, but the code would look like this:
@Component({
selector: "app-edit-customer",
template: `
@if (customer(); as value) {
<app-customer [customer]="value" [showDeleteButton]="true"></app-customer>
}
{{ loadComputed() }}
`,
standalone: true,
imports: [CustomerComponent],
})
export class EditCustomerComponent {
id = input.required({transform: numberAttribute});
customer = signal<Customer | undefined>(undefined);
customerService = inject(CustomerService);
loadComputed = computed(() => {
const id = this.id();
this.customerService.byId(id).then((customer) => this.customer.set(customer));
});
}
What's the difference? First, we've generated a Signal of type void
, which isn't particularly useful, and your fellow developers might not know what to do with a Signal of no value. Second, this only works because loadComputed
is used in the template to keep the Signal alive.
Unlike effect()
, a Signal needs to be called within a reactive context, such as a template, to become reactive.
We can all agree that using computed()
in this case is not ideal.
effect()'s Achilles' Heel: Enforced Asynchrony
Here’s where real issue with effect()
comes in. Unlike computed()
, which runs synchronously, effect()
enforces asynchronous execution. This can lead to serious bugs when immediate state updates are needed.
Let's look at the following example:
@Component({
selector: "app-basket",
template: `
<h3>Click on a product to add it to the basket</h3>
<div class="flex gap-4 my-8">
@for (product of products; track product) {
<button mat-raised-button (click)="selectProduct(product.id)">{{ product.name }}</button>
}
</div>
@if (selectedProduct(); as product) {
<p>Selected Product: {{ product.name }}</p>
<p>Want more? Top up the amount</p>
<div class="flex gap-x-4">
<input [(ngModel)]="amount" name="amount" type="number" />
<button mat-raised-button (click)="updateAmount()">Update Amount</button>
</div>
}
`,
standalone: true,
imports: [FormsModule, MatButton, MatInput],
})
export default class BasketComponent {
private readonly httpClient = inject(HttpClient);
protected readonly products = products;
protected readonly selectedProductId = signal(0);
protected readonly selectedProduct = computed(() => products.find((p) => p.id === this.selectedProductId()));
protected readonly amount = signal(0);
#resetEffect = effect(() => {
this.selectedProductId();
untracked(() => this.amount.set(1));
});
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.selectedProduct()?.name + " added to basket");
}
updateAmount() {
this.httpClient.post("/basket", {id: this.selectedProductId(), amount: this.amount()}).subscribe();
}
}
The BasketComponent
lists some products and allows the user to select one.
After selecting a product, the user can update the amount for that product.
The #resetEffect
resets the amount to 1 whenever a new product is selected. We could have placed this logic inside the selectProduct
method, but linking it to the selectedProductId
Signal ensures that any changes to selectedProductId
— even from other event handlers — will always trigger the reset.
When the user switches to a different product, we want to send the selected product, along with the reset amount of 1, to the server. To achieve this, we add the following request inside selectProduct
:
class BasketComponent {
// ...
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.selectedProduct()?.name + " added to basket");
this.httpClient.post("/basket", {id: this.selectedProductId(), amount: this.amount()}).subscribe();
}
}
If we click on the first product, change the amount to something else, and then select a second product, we will see that the HTTP request still sends the amount from the first product. However, the input field correctly shows the reset value of 1.
It’s not that the #resetEffect
didn’t run — otherwise, the input field wouldn't have updated. The issue is a timing problem.
An effect()
runs asynchronously, whereas the event listener selectProduct
runs synchronously. By the time the HTTP request is sent, the #resetEffect
hasn’t even started executing, so the amount is still the old value.
This is a severe bug. The user sees the correct value, but the server receives the wrong one. Even worse, if the user submits their basket thinking the amount is correct, they could end up paying more and receiving a larger quantity than
expected.
When working with Signals, it's important to understand the concept of a "glitch-free effect." This means that if a Signal changes multiple times synchronously, the frontend is only concerned with the final state. There's no need to update the DOM with intermediate states while a synchronous task is still in progress.
Scheduling the effect()
asynchronously makes sense because it ensures that all synchronous execution has completed.
In this context, effect()
and the template act as the "end" or "final consumer" of a Signal's reactive graph. On the other hand, computed()
is part of the reactive graph itself but is not the final consumer, which is why computed()
runs synchronously.
So far, we've seen that computed()
behaves to effect()
, like pipe()
behaves to subscribe()
in RxJS.
This is where the comparison with RxJS breaks down. In RxJS, a subscription would run synchronously, ensuring that everything stays in sync.
We’ve identified the problem — now, what’s the solution?
The Reset Pattern
The reset pattern, introduced at TechStackNation, solves tricky synchronous Signal updates using computed()
. In these cases, while effect()
may seem simpler, the reset pattern ensures updates happen synchronously.
The pattern places a nested Signal inside a computed()
, initialized with a default value. These Signals act as triggers, and when they change, the computed()
recalculates and updates the Signal synchronously.
Here’s how the #resetEffect
would be re-modeled using computed()
:
class BasketComponent {
protected readonly state = computed(() => {
return {
selectedProduct: this.selectedProduct(),
amount: signal(1),
};
});
}
As soon as the selectedProductId
changes, the computed()
is notified synchronously and is internally marked as dirty. The selectProduct
method then reads the value of amount
and gets the correct value back.
For the sake of completeness, here is the final version of the BasketComponent
:
@Component({
selector: "app-basket",
template: `
<h3>Click on a product to add it to the basket</h3>
<div class="flex gap-4 my-8">
@for (product of products; track product) {
<button mat-raised-button (click)="selectProduct(product.id)">
{{ product.name }}
</button>
}
</div>
@if (state().selectedProduct; as product) {
<p>Selected Product: {{ product.name }}</p>
<p>Want more? Top up the amount</p>
<div class="flex gap-x-4">
<input [(ngModel)]="state().amount" name="amount" type="number" />
<button mat-raised-button (click)="updateAmount()">Update Amount</button>
</div>
}
`,
standalone: true,
imports: [FormsModule, MatButton, MatInput],
})
export default class BasketComponent {
private readonly httpClient = inject(HttpClient);
protected readonly products = products;
protected readonly selectedProductId = signal(0);
readonly #selectedProduct = computed(() => products.find((p) => p.id === this.selectedProductId()));
state = computed(() => {
return {
selectedProduct: this.#selectedProduct(),
amount: signal(1),
};
});
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.#selectedProduct()?.name + " added to basket");
this.httpClient.post("/basket", {id: this.selectedProductId(), amount: this.state().amount()}).subscribe();
}
updateAmount() {
this.httpClient.post("/basket", {id: this.selectedProductId(), amount: this.state().amount()}).subscribe();
}
}
At first glance, and even after, the reset pattern seems like a lot of boilerplate. The effect()
version is much more intuitive.
linkedSignal
Since this pattern is both common and unintuitive, the Angular team has announced plans to introduce utility functions, not just for the reset pattern but for others as well.
As of this writing, Matthieu Riegler from the Angular team has reported a new PR for a utility function called linkedSignal()
, designed to handle the reset pattern. This function allows us to create a WritableSignal
that reacts to changes in another signal.
In this scenario, the following code:
class BasketComponent {
protected readonly selectedProductId = signal(0);
state = computed(() => {
return {
selectedProduct: this.#selectedProduct(),
amount: signal(1),
};
});
}
can be updated to:
class BasketComponent {
protected readonly selectedProductId = signal(0);
protected readonly amount = linkedSignal({
source: this.selectedProductId,
computation: () => 0
})
}
Now, amount
functions like a typical WritableSignal
, but it will automatically reset whenever selectedProductId
changes.
Summary
effect()
has many valid use cases. Avoiding it in real-world applications will lead to poorer code quality.
We can compare the relationship between computed()
and effect()
to the declarative style of using pipe()
in RxJs versus placing side-effects directly in tap()
or subscribe()
. effect()
runs asynchronously, however.
For operations that synchronously modify other Signals, computed()
is required — even if that means sacrificing some readability and maintainability. The risk of introducing bugs due to the asynchronous behavior of effect()
is simply
too high.
Fortunately, the Angular team has already recognized these issues, and we can expect new utility functions, like linkedSignal
to provide solutions for these cases.
Other utility functions that use effect()
internally, while protecting against misuse also exist. Examples include toObservable()
, rxMethod()
from @ngrx/signals, and
explicitEffect()
from ngxtension.
These types of functions will likely become more common in the future, reducing the need for directly writing effect()
in many situations.
Common use cases for effect()
include side-effects that don't result in changes to other Signals. It's also ideal for triggering asynchronous tasks, regardless of whether they eventually produce a new value for a Signal.
Use effect()
. It is a critical part of Signals.
In case you are in doubt, let me give you a mnemonic:
Whenever you have an effect
, like this...
effect(() => {
// side-effects, asynchronous or synchronous Signal updates
});
...and you can wrap it into an asynchronous task like this...
effect(() => {
Promise.resolve().then(() => {
// side effect, asynchronous or synchronous Signal updates
})
});
...you are fine.
I'd like to thank the reviewers of this article: Alain Boudard, Dominik Pieper, and Matthieu Riegler from the Angular Team.
Special thanks to Manfred Steyer for reviewing the original version, and to my GDE colleagues and Michael Egger-Zikes for the insightful discussions that led to this article.
Further Reading:
- Alex Rickabaugh at TechStackNation - Don't Use Effects 🚫 and What To Do
Instead: https://youtu.be/aKxcIQMWSNU?si=Kn31zD8R19AWScTQ - Angular Documentation - Signals: https://angular.dev/guide/signals
- Rainer Hahnekamp - Signals Unleashed, The Full Guide: https://youtu.be/6W6gycuhiN0
- Manfred Steyer - Blog Series on Signals: https://www.angulararchitects.io/en/blog/angular-signals/
- Angular Community - Discussion on Explicit Effect: https://github.com/angular/angular/issues/56155
- Latest updates to effect() in Angular: https://blog.angular.dev/latest-updates-to-effect-in-angular-f2d2648defcd