DEV Community

Cover image for Angular's effect(): Use Cases & Enforced Asynchrony
Rainer Hahnekamp for This is Angular

Posted on • Originally published at rainerhahnekamp.com

Angular's effect(): Use Cases & Enforced Asynchrony

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:

  1. effect() has its righteous place and should be used where necessary.

  2. 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.

  3. 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:

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
  });
})
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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.

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

Why would we use effect() here if computed() is available?

@Component({
  // ...
  template: `Double: {{ double() }}`,
})
class DoubleComponent {
  n = signal(2);
  double = computed(() => this.n() * 2);
}
Enter fullscreen mode Exit fullscreen mode

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:

The most common uses cases for effect() are:

  1. "Signal-exclusive" side effects: When reacting to a Signal's change where the immediate outcome is not a derived Signal.
  2. 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);

  #logEffect = effect(() => console.log(n()));
  #storageSyncEffect = effect(() => localStorage.setItem("n", JSON.stringify({ value: n() })));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]],
  });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
      );
    })
  });
}
Enter fullscreen mode Exit fullscreen mode

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));
  });
}
Enter fullscreen mode Exit fullscreen mode

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 {
  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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


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),
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

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 {
  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();
  }
}
Enter fullscreen mode Exit fullscreen mode

At first glance, and even after, the reset pattern seems like a lot of
boilerplate. The effect() version is much more intuitive.

However, Alex Rickabaugh from the Angular team has presented this pattern, which is a good sign. This means that the team is aware of the problem, and we can expect utility functions to address it in future versions of Angular.

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 to provide solutions for these cases.

Some utility functions already exist that use effect() internally while protecting against misuse. 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 see have the for an effect, like this...

effect(() => {
  // side-effects, asynchronous or synchronous Signal updates
});
Enter fullscreen mode Exit fullscreen mode

...and you can wrap it into an asynchronous task like this...

effect(() => {
  Promise.resolve().then(() => {
    // side effect, asynchronous or synchronous Signal updates
  })
});
Enter fullscreen mode Exit fullscreen mode

...you are fine.


Special thanks to Manfred Steyer for reviewing this article, 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
  • Angular Documentation - Signals

https://angular.dev/guide/signals

  • Rainer Hahnekamp - Signals Unleashed, The Full Guide

  • Manfred Steyer - Blog Series on Signals

Angular Community - Discussion on Explicit Effect

Explicit Tracking for `effect()`: Request for Reconsideration #56155

Which @angular/* package(s) are relevant/related to the feature request?

core

Changelog

June 2, 2024: Added section on relying on external libraryes

Description

We have been refactoring an application towards using Angular's Signals. During this process, we observed a recurring pattern where effects inevitably require the use of untracked. This pattern has revealed several issues:

  1. Frequent Use of untracked: Developers frequently need to use untracked to prevent unintended side effects in signals, making it a standard practice in our codebase.
  2. Error-Prone Nature: The necessity of untracked is not well-known among developers, leading to common errors when it is omitted.
  3. Maintenance Overhead: Constantly ensuring that untracked is correctly applied adds to the maintenance burden and cognitive load on developers.

To address these issues, we propose that Angular effects should adopt an explicit tracking model.

Proposed solution

  1. Explicit Declaration: Developers should explicitly declare dependencies within effects, rather than relying on automatic tracking.
  2. Default Non-Tracking: By default, effects should not track dependencies unless explicitly specified, reducing the need for untracked.

Example Scenario: Currently, developers write:

id = input.required<number>();

effect(() => {
  // Part 1: Tracking
  const id = this.id();

  // Part 2: Execution
  untracked(() => {
    this.dataService.load(id);
  });
})
Enter fullscreen mode Exit fullscreen mode

With explicit tracking, it would look like:

id = input.required<number>();

effect(this.id, (id) => {
  this.dataService.load(id);
});
Enter fullscreen mode Exit fullscreen mode

Benefits:

  1. Reduced Errors: Explicit tracking minimizes the risk of overlooking untracked, leading to more predictable and stable code.
  2. Improved Developer Experience: Clearer dependency management simplifies understanding and maintaining the code.
  3. Enhanced Performance: Explicit tracking can optimize performance by avoiding unnecessary re-computations and side effects.

Internal API and Library Ecosystem: It is noteworthy that some parts of the internal Angular API already utilize automatic untracked. While this is beneficial, other libraries face similar problems and would benefit if the untracking responsibility was handled by the caller. This approach can provide more consistent and error-free usage across different codebases and libraries.

** External Libraries:** It has been suggested that this change be implemented as an external library instead. Most application developers are not aware of explicit tracking, though, and would not expect implicit tracking, leading to bugs. Relying on an external library requires developers to be aware of the problem and actively seek out a solution.

Reference: https://github.com/angular/angular/issues/56155#issuecomment-2137760839

Prior Discussion: This issue was previously raised in

Thank you for considering this enhancement.

Alternatives considered

  • Introducing a fourth "effect-like" function with explicit tracking
  • Keep it as it is

Top comments (5)

Collapse
 
frankitch profile image
Frankitch

Thanks Rainer for this neat and complete article (once again!)

Collapse
 
mfp22 profile image
Mike Pearson

After converting dozens of code bases from imperative to declarative styles, and writing articles specifying exactly how and why it does, yes, I promise you that imperative code harms applications. Every time I do it and test before and after, I find a bug that I accidentally fix.

Collapse
 
rainerhahnekamp profile image
Rainer Hahnekamp

Hey Mike 👋,

It seems like we’re at a bit of a crossroads here. While I haven’t converted dozens of applications, the ones I’ve worked on often showed me the opposite. I’ve seen how going too far with declarative programming can actually make things harder to follow.

You reach a point where it’s difficult to trace where things begin, how the data flows, and where it ultimately ends. In those situations, mixing in some imperative code actually improves readability.

Maybe I don’t fully grasp declarative programming, but from what I’ve experienced, a touch of imperativity here and there doesn’t hurt at all.

All the best,
Rainer

Collapse
 
mfp22 profile image
Mike Pearson

Declarative code is harder to follow if you're trying to read it like imperative code. Unfortunately, I haven't seen hardly any fully declarative code in the wild, so it's nearly impossible to get used to, especially for beginners. Even Redux, which has burdened millions of codebases with boilerplate, failed to communicate why it was even worth it in terms of declarative programming, which was its central value proposition. After all that, even both the NgRx and Redux teams seem to display a lack of appreciation for that central concept. NgRx at least has clear language in the docs specifying that actions are events, and that's awesome; but then they encourage fetching data with actions named like commands, fired when no event actually occurred.

Basically, the ecosystem is hostile to declarative programming, and people blame the wrong thing.

Yes, I fully expect people to overuse effect, thinking it's harmless. It will blow up in their faces, and they'll learn after a few years of pain. I know this because Angular is following in the exact footsteps React set, just 5 years later. It's also just apparent to me because devs always go for shiny, easy-looking footguns.

Declarative code will be adopted incrementally, but it will he widespread someday. The whole ecosystem has to evolve though, one layer at a time. Devtools, great config and default behaviors like Angular Query has, and clear tutorials - they all have to evolve in lock step, or devs slip through the cracks.

Angular was set up in the best position with RxJS, and rather than filling in the gaps in devtools and whatever, people got distracted with 50 operators and just never got the main point of it, just like with Redux. The community has been the blind leading the blind for 8 years. Most "experts" are still writing RxJS like they never had reactive programming click. I spend the lot of time on GitHub looking at projects, so I encounter a lot of training/workshop repositories too, and even situations that are soft ball home runs for switchMap and... Oh, they did THAT. 😑

At this point, the single best thing for the community would be to encourage getting rid of ngOnChanges in favor of input signals and computeds. Those are not hard to follow once you know what to look for, just like all declarative code. But it's the next step in reactive programming for the community. The mindset will graaaaadually shift, and then in 2-3 years most will be clamoring for Angular Query.

Collapse
 
jangelodev profile image
João Angelo

Hi Rainer Hahnekamp,
Top, very nice and helpful !
Thanks for sharing.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.