With an NgRx SignalStore, why does patchState(this.store, { trackFilter: 'web' }) in a component fail to compile, and how should you fix it?
answer
- a default you did not set
- read-only vs writable state source
- a type-level guarantee
- intent-named methods
- protectedState: false, and its lint rule
basics
~20 sSignalStore state is protected by default: the store's public type exposes a read-only state source, and patchState needs a writable one. Add a method such as setTrack() in withMethods; protectedState: false also works but drops the guarantee.
solid answer
~40 s`signalStore()` defaults to **protected state**. Inside `withMethods`, `withHooks` and `withProps` the factory's store argument carries a *writable* state source, but the class handed to components is typed with a *read-only* one, and `patchState` only accepts a writable source, so the call in a component is a type error. The fix is to give the change a name: `setTrack(track)` in `withMethods`, called as `store.setTrack('web')`. You can opt out with `signalStore({ protectedState: false }, ...)`, which NgRx's ESLint rule `prefer-protected-state` flags. The guarantee is **compile-time**: at runtime the slices are ordinary writable signals, so a cast would bypass it. `signalState()` has no such protection, so keep one private and expose its signals.
code
ts · 24 linesimport { Component, inject } from '@angular/core';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
export const ScheduleStore = signalStore(
{ providedIn: 'root' },
withState({ trackFilter: 'all', selectedSessionId: null as string | null, _lastSyncedAt: 0 }),
withMethods((store) => ({
setTrack(trackFilter: string): void {
// allowed: the factory's store argument is writable
patchState(store, { trackFilter, selectedSessionId: null });
},
}))
);
@Component({ selector: 'app-track-picker', template: '...' })
export class TrackPickerComponent {
readonly store = inject(ScheduleStore);
pick(track: string): void {
// patchState(this.store, { trackFilter: track }); // type error: protected state
this.store.setTrack(track);
// this.store._lastSyncedAt(); // type error: private member
}
}go deeper
Recall that SignalStore state is protected by default and that components change state by calling the store's methods.
Explain the read-only versus writable state source types and why patchState compiles in withMethods but not in a component.
Show that protection is type-level, not a freeze, compare it with signalState's lack of protection, and use underscore members to shrink the public surface.
Set the team rule: stores expose intent-named methods, protectedState: false needs a written reason, and the prefer-protected-state lint rule stays on.
## What the compiler is telling you Every SignalStore keeps its state in a hidden **state source**: one writable signal per root slice. `patchState(source, ...)` is typed to accept a `WritableStateSource`, a source whose slice signals can be set. `signalStore()` has two sets of overloads: | Config | Type of the injected store's state source | `patchState(this.store, ...)` in a component | |---|---|---| | none, or `{ protectedState: true }` | read-only `StateSource` | **type error** | | `{ protectedState: false }` | `WritableStateSource` | compiles | Inside the store, the factories of `withMethods`, `withHooks` and `withProps` receive a store argument typed with the writable source, so `patchState(store, ...)` there is fine. The protection draws a line between **code inside the store** and **code that injects it**. ## The right fix: name the change In the conference planner, a track dropdown wants to set `trackFilter`. Instead of patching from the component, add a method: 1. In `withMethods`, define `setTrack(trackFilter: string): void { patchState(store, { trackFilter }); }`. 2. In the component, call `this.store.setTrack('web')`. This is more than ceremony: - The store's public surface lists **every allowed change**, so a reviewer can see who can alter `favouriteIds` by reading one file. - Invariants live in one place. If choosing a track must also clear the selected session, `setTrack` does both, and no component can forget one half. - Tests target methods, not arbitrary patches. ## The opt-out and what it costs `signalStore({ protectedState: false }, withState(...))` makes the injected store writable, so any consumer can call `patchState`. The NgRx docs call protected state the recommended approach, and `@ngrx/eslint-plugin` ships the rule **`prefer-protected-state`**, whose message is that `{ protectedState: false }` should be removed to prevent external state mutations. Typical reasons people reach for the opt-out are prototypes and tests; for tests, `unprotected()` from `@ngrx/signals/testing` exists for that purpose instead. ## What the protection is, and is not - It is a **TypeScript guarantee**. The generated class copies the same writable signals onto the instance whichever option you pick; the option changes the declared type. `unprotected()` itself only checks that the source is writable and returns it re-typed. - It is **not a freeze**. Nothing throws if an updater mutates an array in place. A dev-mode freeze shipped in the 19.0.0 beta and was reverted in 19.0.1; the docs only require immutable updaters. - It does **not** stop writes inside the store. Every method, hook and prop factory can still patch. - A cast such as `patchState(this.store as any, ...)` would compile and work, so code review and lint are what keep it honest. ## Going further: private members Protection covers *writes*. To hide members from consumers entirely, prefix them with `_`: root state slices, props and methods whose names start with an underscore are left out of the store's public type but stay available to later features inside the store. In the planner, `_lastSyncedAt` can be a private slice that `load()` maintains and no component can read. Like protection, this is enforced by types, not at runtime. ## `signalState` is different `signalState({ ... })` returns a signal with a `WritableStateSource`, so anyone holding it can `patchState` it. The NgRx docs keep it in a private class field (`readonly #state = signalState(...)`) and expose only its signals. If you choose `signalState` over a store, you own the encapsulation yourself. ## Spotting the problem in review - A component or service importing `patchState` alongside a SignalStore it injects is the first sign someone wants to write from outside. - A store created with `{ protectedState: false }` and no comment explaining why deserves a question. - A method named `patch` or `update` that forwards an arbitrary partial state to `patchState` reopens the door protection closed; replace it with methods that name each change. - Casts to `any` around a store almost always exist to get past these types. ## Summary of fixes - **Preferred:** a method in `withMethods` per meaningful change. - **Acceptable for tests:** `unprotected(store)` from `@ngrx/signals/testing`. - **Last resort:** `protectedState: false`, with the lint rule silenced deliberately and a reason written down.
- Is SignalStore state protection enforced at runtime?No. Whichever option you choose, the instance holds the same writable slice signals; `protectedState` only changes the declared type from a writable to a read-only state source. `unprotected()` just checks the source is writable and re-types it. A cast bypasses the protection, so lint rules and review are the runtime safeguard.
- How do you hide an internal slice or helper method of a SignalStore from components?Prefix its name with `_`. Root state slices, props and methods starting with an underscore are omitted from the store's public type, so components cannot read or call them, while later features inside the store still can. It is a type-level rule, like protected state.
saying these in an interview costs you the question
- Protected state freezes the state object and throws when something mutates it.
- Setting protectedState: false is the normal way to let components update a store.
- Protected state blocks patchState even inside the store's own withMethods.
- A signalState() instance is protected from outside writes, just like a SignalStore.
- An underscore-prefixed member is removed from the store object at runtime.