DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

effect() is the most misused API in modern Angular

I review a lot of Angular code, and since signals became the default way to manage state, one bug keeps coming back wearing different costumes. It looks like this:

firstName = signal('Ada');
lastName = signal('Lovelace');
fullName = signal('');

constructor() {
  effect(() => {
    this.fullName.set(`${this.firstName()} ${this.lastName()}`);
  });
}
Enter fullscreen mode Exit fullscreen mode

It compiles. It even works — most of the time. And it's wrong in a way that will eventually cost you an afternoon.

The one-line rule

Computing a value → computed(). Causing a side effect → effect(). That's the whole decision tree. If the body of your effect ends with .set() or .update() on another signal, you are building a worse version of computed():

fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
Enter fullscreen mode Exit fullscreen mode

One line, and you gain three things the effect version silently loses. First, it's synchronous: read fullName() right after setting firstName and the value is already correct. Effects are scheduled — they run later, during change detection, so there's a window where fullName is stale. Tests hit that window constantly; production hits it on the worst day of the quarter. Second, it's lazy and memoized: nothing recomputes until someone actually reads it. Third, the dependency graph stays declarative — you can look at a computed() and know exactly what it depends on. An effect that writes signals creates invisible edges in that graph.

The infinite-loop trap

Write to a signal you also read inside the same effect and you've built a reactive ouroboros:

effect(() => {
  // reads count, writes count → schedules itself again
  this.count.set(this.count() + 1);
});
Enter fullscreen mode Exit fullscreen mode

Angular protects you by throwing on signal writes inside effects unless you explicitly opt out — and the history of that opt-out tells you everything about the framework team's opinion. The escape hatch was called allowSignalWrites, and it was removed: writes are now allowed by default but the guidance got stronger, not weaker — because the team saw what people did with the flag. They built derived state with it. If you feel the urge to write a signal from an effect, stop and ask what value you're actually deriving. Nine times out of ten the answer is a computed(). The tenth time, it's usually linkedSignal() — derived state that can also be locally overridden, like a selection that resets when the list changes.

The silent one: conditional reads

This one doesn't crash. It just stops working, quietly:

effect(() => {
  if (this.isEnabled()) {
    console.log('value is', this.value());
  }
});
Enter fullscreen mode Exit fullscreen mode

Signal dependencies are tracked per execution. If isEnabled() is false on a given run, value() is never read on that run — so the effect doesn't depend on it anymore. Flip value all you want: nothing fires until isEnabled changes again. This is by design, and it's a feature when you understand it (dependencies prune themselves), and a haunted house when you don't. The fix when you need the dependency unconditionally: read it before branching.

effect(() => {
  const value = this.value(); // tracked on every run
  if (this.isEnabled()) {
    console.log('value is', value);
  }
});
Enter fullscreen mode Exit fullscreen mode

untracked(), the precision tool

Sometimes you want to read a signal inside an effect without subscribing to it. That's exactly what untracked() is for — and it makes intent explicit instead of accidental:

effect(() => {
  const user = this.currentUser(); // re-run when user changes
  const settings = untracked(this.settings); // just read, don't subscribe
  this.analytics.track('user_changed', { user, settings });
});
Enter fullscreen mode Exit fullscreen mode

In reviews I treat untracked() as a good sign: the author thought about which dependencies they wanted. Its absence around incidental reads is how effects end up re-running on signals nobody meant to watch.

So what is effect() actually for?

Three jobs, and they share a shape: the data leaves the signal graph.

  • Syncing with non-Angular code — writing to localStorage, driving a chart library, talking to a map SDK.
  • Logging and analytics — fire-and-forget observation of state changes.
  • Imperative DOM work that has no declarative equivalent — focus management, canvas drawing, scroll restoration.

Notice what's not on the list: fetching data (that's resource() / httpResource() — I wrote a whole note on why), deriving state (computed()), and resetting forms when an input changes (linkedSignal()). Every time the framework shipped one of those primitives, a category of effects I used to see in review simply disappeared. That's the trajectory: effect() is the escape hatch, and modern Angular keeps shrinking the set of problems that need it.

My honest take after two years of signals in production code: a file with many computed() and few effect() reads like a spreadsheet — you can audit it top to bottom. A file with many effects reads like a Rube Goldberg machine. When a junior asks me which one to use, I tell them: if you can name the value you're producing, it's a computed(). If you can only name the action you're causing, it's an effect(). If you can name neither, neither — go back to the design.

Top comments (0)