An Angular app sets an effectiveTheme signal from an effect() reading prefersDark() and theme(); it causes extra change detection passes and once looped. Why, and what replaces it?
answer
- copying state is not a side effect
- writes allowed, not advised
- a second pass for the copy
- loops when a write re-dirties itself
- derive it instead
basics
~20 sThe effect copies derived state, so the value exists only after the effect runs and writes it; readers already checked must be checked again, and an effect reading what it writes can re-trigger itself. Replace it with computed(), or linkedSignal() if overridable.
solid answer
~50 sSince v19 signal writes inside `effect()` are allowed by default (the old `allowSignalWrites` flag is a deprecated no-op), but allowed is not advised. Here the effect exists only to copy `prefersDark() ? 'dark' : theme()` into `effectiveTheme`. The effect runs at its scheduled point, writes a new value, and that write marks every reader of `effectiveTheme` dirty; any reader already checked in this pass, such as a header higher in the tree, must be checked again, costing another pass. If the effect also reads `effectiveTheme`, or writes something that feeds `theme`, it re-dirties itself and Angular re-runs it before continuing, which loops if the values never settle. Angular's guide warns that propagating state through effects risks `ExpressionChangedAfterItHasBeenChecked`, circular updates and extra change detection. The fix is `effectiveTheme = computed(() => prefersDark() ? 'dark' : theme())`, or `linkedSignal()` if users can override it. Keep the effect only for writing to `localStorage`.
code
ts · 22 linesimport {Component, computed, effect, signal} from '@angular/core';
type Theme = 'light' | 'dark' | 'system';
@Component({
selector: 'app-theme-shell',
template: `<main [attr.data-theme]="effectiveTheme()"><ng-content /></main>`,
})
export class ThemeShell {
readonly theme = signal<Theme>('system');
readonly prefersDark = signal(false);
// Derived, not copied: no extra write, no second pass.
readonly effectiveTheme = computed(() =>
this.theme() === 'system' ? (this.prefersDark() ? 'dark' : 'light') : this.theme(),
);
constructor() {
// The only effect left writes out of the signal graph.
effect(() => localStorage.setItem('app-theme', this.theme()));
}
}go deeper
Recall that effects are for syncing to non-signal APIs, and that a value derived from other signals should be a computed().
Explain why a signal write in an effect can force already-checked views to be checked again, and when an effect re-runs because of its own write.
Diagnose extra passes and loops caused by state-copying effects, refactor them to computed() or linkedSignal(), and keep legitimate writes settling.
Turn this into review guidance: which signal writes in effects are acceptable, how they are justified, and how the team spots propagation effects early.
## The code under suspicion ```ts readonly theme = signal<'light' | 'dark' | 'system'>('system'); readonly prefersDark = signal(false); readonly effectiveTheme = signal<'light' | 'dark'>('light'); constructor() { effect(() => { const next = this.theme() === 'system' ? (this.prefersDark() ? 'dark' : 'light') : this.theme(); this.effectiveTheme.set(next); }); } ``` It looks harmless, and since **v19** it even compiles and runs without warnings: signal writes inside effects are allowed by default, and the `allowSignalWrites` option that used to gate them is deprecated and ignored. The bugs come from timing, not permission. ## Why it costs extra change detection Walk one change through change detection: 1. The user picks `'dark'`; `theme` changes. Readers of `theme` are marked dirty, and so is the effect. 2. Change detection runs. The effect runs at its scheduled point and **writes** `effectiveTheme`. 3. That write marks every reader of `effectiveTheme` dirty. Readers checked later in the same pass simply see the new value. 4. Any reader that was **already checked** in this pass, such as a header component higher in the tree, has to be checked again, so Angular runs another pass. Anything that read `effectiveTheme` between the `theme` change and the effect run saw a stale combination. With a `computed()`, the value is simply correct whenever it is read. There is no intermediate state and no second write. ## Why it can loop An effect re-runs when a signal it read changes, **including during its own run**: Angular re-runs such an effect before moving on with change detection. That is fine if the second run writes the same value, because the signal's equality check stops the cycle. It loops when: - the effect reads the signal it writes and always produces a different value, such as appending or incrementing; - two effects write each other's inputs, or an effect writes something that feeds its own input through a chain; - the written value is a fresh object each time, so `Object.is` never reports it as equal. The symptom is a frozen tab or a change detection error rather than a clear message, which makes this one of the costliest effect mistakes. ## What the guide says Angular's effect guide is blunt: - effects should be **the last API you reach for**; - avoid them **for propagation of state changes**, which "can result in `ExpressionChangedAfterItHasBeenChecked` errors, infinite circular updates, or unnecessary change detection cycles"; - if you are copying data from one signal to another with an effect, **move the source of truth** and use `computed()` or `linkedSignal()`. ## The replacements | Need | Use | Why | |---|---|---| | a value fully determined by other signals | `computed()` | pure, lazy, cached, no extra write | | derived by default but locally overridable | `linkedSignal()` | resets from its source, accepts `set()` | | loading async data from signal params | a resource API | handles loading state and cancellation | | pushing state to a non-signal API | `effect()` | the case effects exist for | | reacting to a user action | the event handler | the write happens where the intent is | For the theme: ```ts readonly effectiveTheme = computed(() => this.theme() === 'system' ? (this.prefersDark() ? 'dark' : 'light') : this.theme(), ); constructor() { effect(() => localStorage.setItem('app-theme', this.theme())); } ``` The effect that remains writes **out** of the signal graph, to storage, and writes no signal. ## When a write inside an effect is legitimate Writes are not banned, and some are reasonable: - recording the result of an imperative API back into a signal, such as a measured size; - resetting an unrelated piece of UI state as a genuine reaction to an external event; - bridging a non-signal source into signals where no dedicated API fits. Even then, check three things: the effect must not read what it writes, or must write a value that settles; the write must not be derivable; and wrapping incidental reads in `untracked()` should keep the dependency list to the signals that really should trigger it. ## Diagnosing it in a real app 1. Search for `.set(` and `.update(` inside `effect(` callbacks. 2. For each, ask whether the written value is a pure function of the signals read. If yes, it is a `computed()`. 3. For loops, look for an effect that reads the signal it writes, or two effects writing each other's inputs. 4. Confirm with Angular DevTools' signal graph, or by logging runs with a `debugName` on the effect.
- In Angular 22, what does passing allowSignalWrites: true to effect() do?Nothing. Since v19 signal writes inside effects are allowed by default; the option is deprecated and in dev mode Angular logs a warning that it no longer affects `effect()`. Remove it rather than relying on it.
- When is it reasonable to set a signal from inside an Angular effect?When the value genuinely comes from outside the signal graph, such as a measurement from an imperative API, or when a write is a real reaction to an external event. The value must not be derivable from signals, and the effect must not re-trigger itself with ever-changing writes.
saying these in an interview costs you the question
- Writing signals inside effects throws unless allowSignalWrites is set.
- An effect copying one signal into another is the idiomatic sync pattern.
- Effects cannot loop, because Angular runs each effect once per pass.
- computed() is only for expensive values; cheap copies belong in effects.
- Wrapping the whole effect body in untracked() is the fix for loops.