Angular Signals: Practical Guidance from Real-World Use

After nearly a year of building with Angular Signals, I’ve settled on a clear set of patterns. Here’s what I’ve learned about when each tool shines and where the pitfalls hide.

Choosing Between Signals and Observables

Signals are the right choice in two situations:

  1. Inside templates;
  2. Whenever you need to react to a value change without any temporal dimension.

Within Angular templates, Signals outshine Observables. They schedule Change Detection automatically, without any pipe infrastructure. They are glitch-free, and reading the same Signal multiple times costs nothing while guaranteeing consistent values. Those advantages alone justify a simple rule: in any new template, any variable that might change should be a Signal.

Outside templates, Signals still work for reactivity, but only when time isn’t part of the equation.

I shared this perspective on Twitter some time ago, and here is the updated and expanded version:

Angular gives you two ways to create reactive variables: Observables and Signals. If you articulate how a variable expresses its reactivity in plain words, the right tool becomes obvious.

When the variable’s role is best described by conditions, reach for a Signal:

  • “if this variable holds this value, render this list”
  • “if this variable holds this value, disable this button”

When the description involves time-related terms, you need an Observable:

  • when the pointer moves…”
  • wait for the upload event, then…”
  • “every time this fires, do that…”
  • until this event…”
  • “ignore for N seconds…”
  • after that request completes…”

Signals lack a time axis entirely. They cannot delay a value; they always hold one, and consumers must always be able to read the current state. This synchronous, always-available nature is their core strength and their boundary.

Signal consumers — computed(), effect(), and templates — do not guarantee that every written value gets read. An updated Signal will be consumed eventually, not immediately after the write, as Observables would. Each consumer decides when to pick up the new value via its own scheduling. That moment could be “in the next task,” “at the next Change Detection cycle,” or some other point entirely, dictated by the consumer.

When computed() Makes Sense

Use it whenever you like!

computed() is the standout feature of Angular Signals — incredibly convenient and safe. It pushes your code toward a declarative style (explored further in this article).

Two constraints govern computed() usage:

  1. Avoid side effects. It should produce a new result and nothing more. Do not touch the DOM, mutate variables via this, or invoke functions that could. Never push values into Observables from inside, as that triggers unintended reactive context propagation (detailed below for effect()). computed() must remain a pure function.
  2. No asynchronous operations. This function forbids Signal writes (which is a helpful guard), but it cannot track async code. Angular Signals are strictly synchronous, so if you find yourself wanting setTimeout(), Promises, or any other async construct in a computed(), that signals a design flaw.

When effect() Is Appropriate

Angular’s official docs advise that effect() should be rare and warn against its casual use (archived copy, in case the docs change).

That guidance is sound: you rarely need effect() — provided your code is declarative.

Your need for effect() grows with the amount of imperative logic in your codebase. No program is entirely declarative, but we should push toward it, minimizing effect() usage to match.

Beyond the documented dangers (infinite loops, Change Detection errors), a subtler hazard exists: effects execute within a reactive context, and any code they call runs in that same context. If that code reads Signals, those become dependencies of your effect. Alex Rickabaugh explains the mechanics here.

I won’t advocate for effect(), but if you must use it, follow these safety guidelines:

  1. Keep the function passed to effect() minimal. A smaller body is simpler to read and simpler to debug.
  2. Read all needed Signals first, then shield the remaining logic with untracked():
    effect(() => {
      // reading the signals we need
      const a = this.a();
      const b = this.b();
      
      untracked(() => {
        // rest of the code is here - this code should not
        // modify the signals we read above!
        if (a > b) {
          document.title = 'Ok';
        }
      });
    });

Combining Signals and Observables

This is perfectly acceptable!

Your application will contain both Signals and Observables, if only because Signals cannot cover every kind of reactivity (see the full reasoning above). This is not a problem; it is expected design.

When you need a value sourced from an Observable inside a computed(), create a Signal in the component with toSignal() — outside the computed() body.

To read a Signal within an Observable’s pipe(), two scenarios apply:

  1. If your Observable must react to the Signal’s changes, convert the Signal into an Observable and combine them with a join operator.
  2. If you simply need the Signal’s current snapshot and don’t care about future updates, you can read it directly inside your operators or subscribe(). Observables do not create a reactive context, so untracked() is unnecessary here.
Reviewers
Reviewers