#5147·platform

fix(store-devtools): toSignal() in StoreDevtools writes to signal in reactive context → NG0600

Author: christophw-birdCreated Apr 17, 2026Updated Apr 18, 2026
LabelsProject: Store DevtoolsNeeds Reproduction

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():

typescript
Object.defineProperty(state$, 'state', {
  value: toSignal(state$, { manualCleanup: true, requireSync: true }),
});

state$ is derived from liftedStateSubject, which emits from a queueScheduler-scheduled subscription:

typescript
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.nextsignal.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 liftedStateSubjecttoSignal 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():

typescript
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

  • Related historical issues: #3891, #3892, #3932 — those were fixed in @ngrx/store v16.0.1 and Angular v16.0.4, but the store-devtools toSignal() path was never addressed.
  • @angular-architects/ngrx-toolkit already uses untracked() 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