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 → throwInvalidWriteToSignalError
This 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: All
Other information
I would be willing to submit a PR to fix this issue
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 orcomputed()evaluation will intermittently throw:Stack trace (Angular 21 + NgRx 21.1.0):
This 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, theStateObservable's.statesignal is created withtoSignal():state$is derived fromliftedStateSubject, which emits from aqueueScheduler-scheduled subscription:When an action is dispatched synchronously during template evaluation or inside a
computed(), thequeueSchedulerprocesses the emission on the same call stack. The chainliftedStateSubject.next()→state$→toSignal()'s internalsub.next→signal.set()fires within the reactive read context, and Angular throws NG0600.The
connectInZone/emitInZoneoption does not help here — it controls the extension communication path, not theliftedStateSubject→toSignalpath.The
logOnlyoption does not help either — even withlogOnly: true, the full subscription chain and signal write still runs.Suggested fix
Replace the bare
toSignal()with a manual signal that writes viauntracked():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 orcomputed().Versions of NgRx, Angular, Node, affected browser(s) and operating system(s)
Other information
computedor aneffectby default. UseallowSignalWritesin theCreateEffectOptionsto enable this inside effects. #3891, Dispatching action ends up with error 'Writing to signals is not allowed' #3892, NG0600: Writing to signals is not allowed in acomputedor aneffectby default #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).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