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:

https://youtu.be/WgdnW_nJuRA

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.

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:

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() })));
}

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
}

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


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

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
});

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


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:

Leave a Reply