fix(store-devtools): toSignal() in StoreDevtools writes to signal in reactive context → NG0600
Which @ngrx/* package(s) are the source of the bug?
store-devtools
Minimal reproduction of the bug/regression with instructions
Any application that registers provideStoreDevtools() and dispatches an action synchronously during template evaluation or computed() evaluation will intermittently throw:
NG0600: Writing to signals is not allowed in a `computed`Stack trace (Angular 21 + NgRx 21.1.0):
@ngrx/store-devtools/fesm2022/ngrx-store-devtools.mjs (liftedStateSubscription)
liftedStateSubject.next(state);
↓
@angular/core/fesm2022/rxjs-interop.mjs (toSignal subscription)
next: value => state.set({...})
↓
@angular/core/fesm2022/_effect-chunk.mjs
signalSetFn → throwInvalidWriteToSignalErrorThis is a timing-dependent race condition. It is more likely to occur in production (optimized builds, tighter CD) but can happen in any environment.
Root cause
In modules/store-devtools/src/devtools.ts, the StateObservable's .state signal is created with toSignal():
Object.defineProperty(state$, 'state', {
value: toSignal(state$, { manualCleanup: true, requireSync: true }),
});state$ is derived from liftedStateSubject, which emits from a queueScheduler-scheduled subscription:
const liftedAction$ = merge(/* ... */).pipe(observeOn(queueScheduler));
// ...
.subscribe(({ state, action }) => {
liftedStateSubject.next(state); // ← triggers toSignal's signal write
// ...
});When an action is dispatched synchronously during template evaluation or inside a computed(), the queueScheduler processes the emission on the same call stack. The chain liftedStateSubject.next() → state$ → toSignal()'s internal sub.next → signal.set() fires within the reactive read context, and Angular throws NG0600.
The connectInZone / emitInZone option does not help here — it controls the extension communication path, not the liftedStateSubject → toSignal path.
The logOnly option does not help either — even with logOnly: true, the full subscription chain and signal write still runs.
Suggested fix
Replace the bare toSignal() with a manual signal that writes via untracked():
import { signal, untracked } from '@angular/core';
// Instead of:
// Object.defineProperty(state$, 'state', {
// value: toSignal(state$, { manualCleanup: true, requireSync: true }),
// });
// Use:
const stateSignal = signal<any>(unliftState(liftedInitialState));
state$.subscribe((value) => {
untracked(() => stateSignal.set(value));
});
Object.defineProperty(state$, 'state', {
value: stateSignal.asReadonly(),
});This ensures the signal write never inherits a reactive context from the caller, while preserving the same observable-to-signal bridge behavior.
Expected behavior
provideStoreDevtools() should not throw NG0600, regardless of whether actions are dispatched synchronously during template evaluation or computed().
Versions of NgRx, Angular, Node, affected browser(s) and operating system(s)
NgRx: 21.1.0
Angular: 21.2.8
Node: 22
Browsers: Chrome, Safari (all browsers — framework-level error)
OS: AllOther information
- Related historical issues: #3891, #3892, #3932 — those were fixed in
@ngrx/storev16.0.1 and Angular v16.0.4, but thestore-devtoolstoSignal()path was never addressed. @angular-architects/ngrx-toolkitalready usesuntracked()for signal writes in their devtools integration (see 741d30d).- Angular 19+ loosened signal write restrictions in effects (PRs angular/angular#56501, #57874), but writes during
computed()and template evaluation remain forbidden — which is the context this bug hits.
I would be willing to submit a PR to fix this issue
- Yes
- No
Source: ngrx/platform