On This Page
Angular NG0600: Writing to Signals Is Not Allowed in a computed — Fix
The Error
ERROR RuntimeError: NG0600: Writing to signals is not allowed in a `computed`.
at throwInvalidWriteToSignalErrorFn (core.mjs:...)
at signalSetFn (core.mjs:...)
at WritableSignalImpl.set (core.mjs:...)
at CartComponent.<computed> (cart.component.ts:14:18)
at producerRecomputeValue (core.mjs:...)
at producerUpdateValueVersion (core.mjs:...)
at computedGet (core.mjs:...)
at CartComponent_Template (cart.component.html:3:12)
at executeTemplate (core.mjs:...)
at refreshView (core.mjs:...)Docs: https://angular.dev/errors/NG0600. Frame names and line numbers vary by version; the identifying frames are signalSetFn/set called under computedGet or executeTemplate.
Wording differs by context and version:
- v17–v18: the message was "Writing to signals is not allowed in a
computedor aneffectby default. UseallowSignalWritesin theCreateEffectOptionsto enable this inside effects." That is where the effect part of the old advice comes from. - v19+: per the Angular team's post on
effect()changes,allowSignalWriteswas removed in v19 and effects may write signals by default. Perangular.dev/errors/NG0600, the error now covers writes during acomputed, during alinkedSignalcomputation, and during template rendering. Check the exact string in your installed@angular/core(node_modules/@angular/core/fesm2022/*.mjs, search forNG0600), because the suffix changes by context. - Production: the dev message is replaced by the bare code and a link to the error page; the throw itself still happens, since it protects graph consistency, not just a dev assertion.
How to Reproduce It
npx @angular/cli@22 new ng0600-repro --defaults
cd ng0600-repro
ng version # Node must satisfy the CLI's supported rangesrc/app/app.ts (v22 zoneless default; no provideZonelessChangeDetection() call needed in generated apps since v21):
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<button (click)="price.set(price() + 5)">+5</button>
<p>Price: {{ price() }} | Tax: {{ tax() }} | Total: {{ total() }}</p>
`,
})
export class App {
price = signal(100);
tax = signal(0);
// BUG: a computed that writes
total = computed(() => {
const t = this.price() * 0.2;
this.tax.set(t); // NG0600 here
return this.price() + t;
});
}ng serveThe first render evaluates total() from the template; the set inside the computed throws and the view fails to render. The same error appears when a template calls a method that writes ({{ label() }} where label() increments a counter signal).
Triggers: any environment; independent of zone vs zoneless, SSR vs CSR, dev vs production.
Version Behaviour Matrix
| Release | set() in computed | set() in effect | Notes |
|---|---|---|---|
| v17 | NG0600 | NG0600 unless allowSignalWrites: true | Signals developer preview; effect experimental-ish |
| v18 | NG0600 | same | Message mentions both computed and effect |
| v19 | NG0600 | Allowed; allowSignalWrites removed | linkedSignal and resource() arrive experimental |
| v20 | NG0600 | Allowed | Core signal APIs stable |
| v21 | NG0600 | Allowed | Zoneless default for new apps; Signal Forms experimental |
| v22 | NG0600 | Allowed | resource() family stable; verify against the v22 release post |
Ecosystem: NgRx SignalStore and toSignal follow the same rule; anything you call from a computed is "inside" it, including service methods. v17/v18 are out of support as of this run (support is ~6 months active + 12 months LTS; check angular.dev/reference/releases).
Why It Happens — Surface Level
A computed is supposed to be a pure function of other signals. Writing to a signal from within it changes state while Angular is in the middle of deciding what the current state is. Angular refuses: it throws NG0600 at the set/update call. Templates are treated the same way, because reading {{ x() }} while rendering must not mutate anything that has already been rendered.
The usual real-world causes: deriving a second value and "caching" it in a signal, calling a service method from a computed that secretly updates a store, a template-bound method that bumps a counter, or a linkedSignal computation that sets a sibling.
Why It Happens — Under the Hood
Signals form a graph of producers and consumers. Each producer has a version number; a computed is both a consumer (of the signals it reads) and a producer (to whoever reads it). Reads are recorded by setting an "active consumer" for the duration of the computation. computed is lazy: it recomputes only when read and only if a dependency's version changed, then caches the result.
The write guard hooks into that same mechanism. signalSetFn checks whether the current active consumer forbids writes (consumerAllowSignalWrites is false for computeds and for the template consumer). If so, it calls the throw function that produces NG0600. Why forbid it?
- Glitch-freedom. Propagation marks dependents dirty and recomputes lazily on read. A write mid-recompute would dirty nodes that may already have been read in this pass, so some consumers would see the pre-write value and others the post-write value.
- Termination. A computed that writes a signal it (transitively) reads would re-dirty itself forever.
- Laziness. A computed may run zero, one or many times depending on who reads it. A side effect inside it would be nondeterministic.
Effects are different: they are scheduled consumers that run after the graph settles, so a write schedules another run rather than corrupting an in-flight read. That's why v19 could drop the flag. The risk moved from "forbidden" to "your problem": an effect that writes a signal it also reads loops.
untracked() suppresses the error because it clears the active consumer for the callback, so the guard sees no consumer. It also means any signal read inside is not a dependency, which is how stale values appear.
The Fix
Best: make the derived value a computed, not a written signal.
export class App {
price = signal(100);
- tax = signal(0);
-
- total = computed(() => {
- const t = this.price() * 0.2;
- this.tax.set(t);
- return this.price() + t;
- });
+ tax = computed(() => this.price() * 0.2);
+ total = computed(() => this.price() + this.tax());
}If the derived value must also be user-writable (e.g. a selection that resets when the options change), use linkedSignal:
import { linkedSignal, signal } from '@angular/core';
options = signal(['a', 'b', 'c']);
selected = linkedSignal<string[], string>({
source: this.options,
computation: (opts, prev) => (prev && opts.includes(prev.value) ? prev.value : opts[0]),
});Do not write other signals inside the computation; that raises NG0600 too.
If it is a genuine side effect (logging, syncing to localStorage, calling an imperative API), use effect (v19+ may write signals, but prefer not to):
constructor() {
effect(() => localStorage.setItem('price', String(this.price())));
}Template-bound method that writes: move the write into an event handler, ngOnInit, or an effect, and bind to a signal or computed instead.
Quick unblock, with cost: untracked(() => this.tax.set(t)). This silences the error but keeps the problem: the computed now has a hidden side effect that runs a nondeterministic number of times, and t isn't tracked if you read signals inside. Use only when the write is intentional and cannot be relocated, per the docs. Also avoid setTimeout(() => this.tax.set(t)) — it hides the symptom and causes extra render passes.
Best Practices & The Better Design
- Model state as a small set of writable signals (the sources of truth) and everything else as
computed. - Keep
computedbodies pure: no service calls that mutate, noDate.now()-driven writes, no logging with side effects. - Prefer
linkedSignalover "effect that resets a signal when another changes". - For async derived data use
resource()/httpResource()(stable in v22) rather than writingloading/datasignals by hand. - If a template calls a method, make it read-only, or replace it with a
computed.
Right-way component (Angular 22, zoneless default):
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<button (click)="bump()">+5</button>
<p>Price {{ price() }} / Tax {{ tax() }} / Total {{ total() }}</p>
`,
})
export class App {
readonly price = signal(100);
readonly tax = computed(() => this.price() * 0.2);
readonly total = computed(() => this.price() + this.tax());
bump() { this.price.update(p => p + 5); }
}How to Prevent It Long-Term
- Code review rule: a
computedorlinkedSignalbody contains no.set(,.update(, or calls to methods that might. - Lint with
angular-eslintand@typescript-eslint; add a customno-restricted-syntaxrule for.set(insidecomputed(callbacks if the team hits this often. - Run
ng build --configuration productionand component tests (Vitest +TestBed) in CI; NG0600 is deterministic, so a render test catches it. - Make "is this derivable?" part of design review before adding a signal.
- Use Angular DevTools' signal graph (v22) to see which node is writing and what depends on it.
- Follow the Update Guide when upgrading across v18 → v19: code that used
allowSignalWrites: truecan drop it; code relying on effects to sync state is the usual candidate forcomputed/linkedSignal.
Related articles: effect loops and injection context (NG0203); zoneless staleness; toSignal initial values.
Key Takeaways
- NG0600 means a read-only reactive context (
computed,linkedSignalcomputation, template) tried to write a signal. - v19 removed
allowSignalWrites; effects can write now, but writing in effects is usually a sign you wantcomputedorlinkedSignal. untracked()silences it and keeps the bug; relocate the write instead.- Derive, don't sync: one writable source, many computeds.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.