Signals Primer

If Signals are already old hat for you, feel free to jump ahead.

A Signal acts as a wrapper around a value. You instantiate one with the signal() function. Retrieving its value is done by invoking the Signal itself like a function. To change that value, you call either set() or update().

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

Signal data must be treated as immutable. When the Signal stores an object, a fresh object reference is required for any change, which typically involves creating a shallow copy.

With computed(), you establish a derived value. This Signal tracks other Signals and recalculates its result whenever its dependencies are altered.

Signals produced by signal() are classified as WritableSignal, whereas computed() yields a generic Signal.

In the dependency graph, a Signal that notifies others is termed a producer. Conversely, the Signal receiving that notification and relying on it is the 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

For executing code in response to Signal changes, you turn to the effect() function. Its syntax mirrors computed(), but it does not return a new Signal.

An effect() executes its logic asynchronously—at least on its initial run—and subsequently whenever its producer sends a notification.

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

Notice that there isn't an output for the value 3. This results from a sequence of synchronous changes; the effect() only sees the final value once the synchronous execution finishes.

Watch out for implicit tracking. If your effect() invokes a method or function, every Signal referenced within that context gets tracked automatically.

To prevent this, you can wrap the code in untracked.

effect(() => {
  const value = someSignalWeWantToTrack();

  untracked(() => {
    someService.doSomething(value);
  });
})
Enter fullscreen mode Exit fullscreen mode

For a deeper dive, consult the official Angular documentation.

computed() or effect(): A Matter of Style?

There is a widespread tendency to counsel against the use of effect(). On some social channels, you might even find assertions that it should be avoided entirely, a claim that doesn't hold up practically.

The official Angular documentation offers this guidance: "Avoid using effects for the propagation of state changes."

The typical examples in these discussions are straightforward and usually represent clear-cut scenarios where computed() would be the better fit. To me, this is a given.

Our collective experience with Angular and RxJS has taught us to recognize this as 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

Calculating double here constitutes a side effect. The preferred approach is to use derived Observable streams rather than setting up 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 style leans declarative. There's no explicit subscription or manual value assignment; the computation is defined and linked to its source, making for cleaner, more maintainable code.

The same logic maps to effect() and computed(). Invoking effect() is an imperative act, while computed() follows a declarative paradigm.

@Component({
  // ...
  template: `Double: {{ double }}`,
})
class DoubleComponent {
  n = signal(2);
  double = 0;

  constructor() {
    effect(() => (this.double = this.n() * 2));
  }
}
Enter fullscreen mode Exit fullscreen mode

If computed() is available, why would one opt for effect()?

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

Hardly anyone would contemplate using an effect() for such a situation.

The conversation often gets bogged down in the imperative-versus-declarative debate. That's a matter of stylistic preference; using effect() in these instances does not introduce bugs or performance issues.

This can lead people to assume it's harmless to use effect() for updating other Signals. In certain situations, effect() can even be the more obvious choice for readability.

The true concern lies in effect()'s asynchronous behavior, which can introduce subtle and significant bugs. We'll get to a concrete example in a moment, but let's first explore the scenarios where effect() is genuinely the right tool.

The Role of effect() in Angular Applications

The recent wave of caution around effect() has created some uncertainty within the Angular community. Some developers may be unaware of the potential downsides, while others shy away from using effect() altogether, even when it's the most suitable solution for the task at hand.

Several influential voices in the Angular ecosystem have shared their perspectives on this matter:

The primary scenarios where effect() proves most valuable are:

  1. Side effects tied exclusively to signals: When a Signal changes and the direct result isn't a derived Signal.
  2. Signal updates requiring asynchronous work: When an effect() needs to modify another Signal but must first await data from a server.

Practical Side Effect Scenarios

A typical illustration involves logging a Signal modification or persisting data to 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 you need to interact with the DOM directly, effect() is also the appropriate instrument. For instance, binding 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

Form synchronization is another frequent application:

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

The common thread across these examples is their reaction to Signal changes without updating other Signals.

This pattern mirrors how we handled side effects with Observable, where we performed side effects within 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

Handling Asynchronous Signal Updates

Arguably the most prevalent use of effect() occurs when a Signal value changes and you must asynchronously retrieve data before updating another Signal.

Consider a scenario where a Signal holds a customer id derived from route parameters. With this id, you must fetch customer details from a server and populate another Signal with the result:

@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

Here, loadEffect monitors the id Signal, initiates an asynchronous request for customer data, and then assigns the response to the customer Signal.

One might think loadEffect is setting a derived value, but because an async operation is in play, computed() is not a viable choice. computed() mandates an immediate return value, which is impossible in this situation.

It's possible to move this loading logic into a service, but that would merely relocate the effect() call elsewhere.

In a large application where data fetching often depends on route parameters and you're already leveraging effect() for this, there's no issue with that approach.

At present, your only mechanisms to react to Signal changes are computed() and effect(). If computed() isn't suitable, then effect() is the appropriate alternative.


When discussing asynchronous tasks, it's important to acknowledge the ever-present RxJS.

While RxJS excels at managing complex async flows, this piece is centered on effect()'s role. I'll delve into RxJS more thoroughly in a separate write-up.

If you choose an Observable, you'll need to convert the Signal first. That's where toObservable() comes in, but interestingly, it relies on an effect() internally.

Remember, for handling asynchronous race conditions, RxJS remains the definitive tool.


Let's also consider an attempt to force a computed() in this scenario. It's technically possible, though the code would be something 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? Firstly, we've created a Signal of type void, which isn't particularly helpful, and colleagues might be puzzled about how to use a Signal that carries no value. Secondly, this only functions because loadComputed is referenced in the template to keep it active.

Contrary to effect(), a Signal must be evaluated within a reactive context, like a template, to be triggered.

It's fair to say that forcing computed() here is far from ideal.

The Achilles' Heel of effect(): Mandatory Asynchrony

This is where the genuine challenge with effect() emerges. Unlike computed(), which operates synchronously, effect() mandates an asynchronous execution pattern. This distinction can result in critical failures when immediate state changes are required.

Take a 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 displays a list of products where the user can make a selection. After picking a product, they can adjust its quantity.

The #resetEffect sets the amount to 1 whenever a different product is chosen. While this logic could be placed in the selectProduct method, tying it to the selectedProductId Signal ensures that any modification to selectedProductId—from any event handler—will consistently force the reset.

When the user picks a new product, the goal is to transmit the selected product, along with the reset amount of 1, to the backend. To accomplish this, we add the following request within 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 the first product, modify the amount, and then choose a second product, we observe that the HTTP request still contains the amount from the first product. However, the input field displays the reset value of 1 correctly.

It's not that #resetEffect failed to execute—otherwise, the input wouldn't have refreshed. The core problem is a timing discrepancy.

An effect() executes asynchronously, while the event listener selectProduct operates synchronously. At the moment the HTTP request is sent, #resetEffect has yet to run, so the amount remains the previous value.

This constitutes a significant flaw. The UI shows the correct value, yet the server receives incorrect data. In a worst-case scenario, a user might submit their basket assuming the displayed amount is correct, leading to charging them for a different quantity than they receive.


Up to this point, we've noted that computed() relates to effect() in a manner similar to how pipe() relates to subscribe() in RxJS.

However, this is where the RxJS comparison falls short. With RxJS, a subscription executes synchronously, maintaining consistency across the board.


We've pinpointed the issue—what's the remedy?

The Reset Pattern

The reset pattern, first demonstrated at TechStackNation, offers a solution for tricky synchronous Signal updates through computed(). While effect() might appear to be the more straightforward option, the reset pattern guarantees that updates propagate synchronously.

This approach involves placing a nested Signal inside a computed() with a default initialization value. These inner Signals serve as triggers; whenever they change, the computed() re-evaluates and updates the Signal in a synchronous manner.

The following shows how #resetEffect can be re-constructed with computed():

class BasketComponent {
  protected readonly state = computed(() => {
    return {
      selectedProduct: this.selectedProduct(),
      amount: signal(1),
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

The moment selectedProductId is modified, the computed() receives a synchronous notification and is flagged as dirty internally. When selectProduct subsequently reads amount, it receives the updated value.

Below is the complete BasketComponent implementation for completeness:

@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 closer inspection — the reset pattern involves substantial boilerplate. The effect() approach reads much more naturally.

However, Alex Rickabaugh from the Angular team has endorsed this pattern, which carries weight. It signals that the team recognizes the problem and that dedicated utility functions may arrive in upcoming Angular releases.

Summary

effect() serves numerous legitimate purposes. Circumventing it entirely in production applications will degrade overall code quality.

The relationship between computed() and effect() parallels the declarative pipe() approach in RxJs versus embedding side-effects directly inside tap() or subscribe(). The key difference is that effect() executes asynchronously.

When operations must synchronously alter other Signals, computed() is the only safe choice — even if readability and maintainability suffer. The potential for bugs stemming from the asynchronous nature of effect() outweighs those concerns.

Thankfully, the Angular team has already acknowledged these limitations, and forthcoming utility functions should address these scenarios.

Several utilities already wrap effect() internally while shielding developers from its pitfalls. Notable examples are toObservable(), rxMethod() from @ngrx/signals, and explicitEffect() from ngxtension.

As such helpers grow more prevalent, the need to author raw effect() calls will diminish across a wide range of scenarios.

Typical effect() use cases involve side-effects that do not update other Signals, as well as kicking off asynchronous tasks — whether or not those tasks ultimately produce new Signal values.

Embrace effect(). It is an integral component of Signals.

If you're still unsure, consider this mnemonic:

Whenever you find yourself needing an effect, like this...

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

...and you can enclose it in an asynchronous task like this...

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

...then you are good to go.


The author extends gratitude to Manfred Steyer for reviewing this article, and to fellow GDE colleagues and Michael Egger-Zikes for the fruitful discussions that shaped its content.


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

core

Update History

June 2, 2024: Added a discussion about relying on external libraries

Overview

During a recent migration of an application to Angular's Signals, a consistent challenge emerged: effects frequently demand the use of untracked. This recurring necessity has brought several complications to light:

  1. Widespread untracked Usage: The need to apply untracked to avoid unintended side-effect triggers has become routine across the codebase.
  2. High Risk of Mistakes: The rationale for when untracked is required isn't widely understood, which leads to frequent oversights and bugs.
  3. Increased Maintenance Load: Continuously verifying that untracked is placed correctly adds extra cognitive strain and upkeep effort for developers.

In light of these challenges, we suggest that Angular effects move to a system where dependency tracking is explicit rather than automatic.

Suggested Approach

  1. Declare Dependencies: Instead of relying on automatic tracking, developers should spell out their dependencies directly within effects.
  2. Opt-In Tracking: Effects should not track dependencies by default. Only when a developer explicitly opts in should tracking occur, which would eliminate the frequent need for untracked.

Example Situation: Currently, the code looks like this:

id = input.required<number>();

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

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

With explicit tracking, the implementation changes to:

id = input.required<number>();

effect(this.id, (id) => {
  this.dataService.load(id);
});

Advantages:

  1. Fewer Mistakes: Explicit tracking removes the guesswork around untracked, making the code more predictable and robust.
  2. Better Developer Workflow: Managing dependencies becomes clearer, simplifying both comprehension and upkeep.
  3. Potential Performance Gains: By specifying what to track, unnecessary re-runs and side-effects can be minimized, possibly enhancing performance.

Internal APIs and Library Impact: It's worth pointing out that certain internal Angular APIs already incorporate automatic untracked. While that's helpful, other libraries encounter the same issues and would see improvements if the responsibility for untracking shifted to the caller. This would lead to more consistent, error-resistant usage across diverse codebases and frameworks.

Third-Party Libraries: Some have proposed implementing this via an external library. The issue is that most application developers are not familiar with the concept of explicit tracking and would not anticipate implicit tracking, which leads to errors. The use of an external library hinges on developers already being aware of the problem and proactively searching for a fix.

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

Earlier Conversations: This topic has been brought up before in:

We appreciate your time in evaluating this proposal.

Other Options Explored

  • Adding a separate function that behaves like an effect but with mandatory explicit tracking.
  • Maintaining the current implementation unchanged.