effect() is the most misused API in modern Angular
Most signal bugs I see in code review are the same bug: an effect() doing a computed()'s job. The one-line rule that fixes it, the infinite-loop trap, and the rare cases where effect() is actually right.
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()}`);
});
}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()}`);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);
});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());
}
});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);
}
});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 });
});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.