skip to content

What is an NgRx SignalStore, and how does it differ from an Angular service that keeps its own writable signals?

level: juniorimportance: must knowfreq 62%

answer

  1. a function that returns a class
  2. features composed in order
  3. state slices become signals
  4. patchState, not set()
  5. state protected by default

basics

~20 s

An NgRx SignalStore is an injectable class built by signalStore() from features: withState turns state slices into signals, withComputed and withMethods add derived values and operations, and by default only the store's own methods can change state.

solid answer

~40 s

`signalStore()` from `@ngrx/signals` takes a sequence of features and returns an injectable class. `withState` creates one signal per root state slice (a `DeepSignal` when the slice is an object literal), `withComputed` adds derived signals, `withMethods` adds the operations, and writes go through `patchState(store, ...)`. Compared with a hand-written service holding `signal()` fields, you get one fixed shape instead of a per-team convention, nested signals for free, store-level lifecycle hooks with `withHooks`, and **protected state**: the store's public type exposes state as read-only, so only its own methods can update it. It is still an ordinary Angular provider, registered nowhere until you pass `{ providedIn: 'root' }` or list it in a `providers` array.

code

ts · 30 lines
ts
import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';

type Session = { id: string; title: string; track: string };

export const ScheduleStore = signalStore(
  { providedIn: 'root' },
  withState({
    sessions: [] as Session[],
    favouriteIds: [] as string[],
    trackFilter: 'all',
  }),
  withComputed(({ sessions, trackFilter }) => ({
    visibleSessions: computed(() =>
      trackFilter() === 'all' ? sessions() : sessions().filter((s) => s.track === trackFilter())
    ),
  })),
  withMethods((store) => ({
    setTrack(trackFilter: string): void {
      patchState(store, { trackFilter });
    },
    toggleFavourite(id: string): void {
      patchState(store, ({ favouriteIds }) => ({
        favouriteIds: favouriteIds.includes(id)
          ? favouriteIds.filter((f) => f !== id)
          : [...favouriteIds, id],
      }));
    },
  }))
);

go deeper

for a junior

Recall that signalStore() returns a class built from features, that state slices are signals you read with (), and that patchState is how state changes.

for a middle

Explain what each core feature adds, why exposed state is typed without set(), and how providedIn: 'root' differs from listing the store in providers.

for a senior

Argue when a SignalStore beats a hand-written signal service: enforced read-only state, DeepSignals, hooks, and features reused across many stores.

for a principal

Frame the choice as team consistency: one store shape across features lowers review cost, at the price of a dependency and its release cadence.

## What `signalStore()` returns A **SignalStore** comes from the `@ngrx/signals` package. You do not write a class for it; you call `signalStore()` with a list of **features**, and it returns a class for you. Angular can inject that class like any service. Each time Angular creates an instance, the constructor runs the features in order, and each feature adds members to the instance. For a conference schedule planner, the store might hold the list of `sessions`, the attendee's `favouriteIds` and a `trackFilter`. Components inject the store, read its signals in templates and call its methods from event handlers. ## The building blocks | Feature | What it adds | Schedule-planner example | |---|---|---| | `withState` | one signal per root state slice | `sessions`, `favouriteIds`, `trackFilter` | | `withComputed` | derived, read-only signals | `visibleSessions` filtered by track | | `withMethods` | functions that read state and call `patchState` | `setTrack()`, `toggleFavourite()` | | `withHooks` | `onInit` / `onDestroy` for the store instance | load the schedule when the store is created | Other features (`withProps`, `withLinkedState` and reusable custom features) extend the same pattern, but these four are what most stores are made of. ## Reading and writing state - **Reading.** Every root slice is a signal: `store.trackFilter()` in a template or a `computed`. When a slice holds an **object literal**, the store wraps it in a `DeepSignal`, so `store.filter()` returns the whole object and `store.filter.day()` is its own signal, created lazily on first access. Arrays and primitives stay plain signals. - **Writing.** The exposed signals are typed as read-only `Signal`s, with no `set()` or `update()`. State changes go through `patchState(store, partialState)` or `patchState(store, (state) => partialState)`, which updates only the slices you name. - **Snapshot.** `getState(store)` returns a plain object of the current state; inside a reactive context such as `computed` or Angular's `effect()` it also tracks every slice. - **Protection.** By default the store is created with **protected state**. The type a component sees exposes state read-only, so `patchState(this.store, ...)` in a component does not compile. Writes belong in methods defined by `withMethods`, which names every change the store allows. ## How it compares with a service that holds signals A plain Angular service with `private readonly _favourites = signal<string[]>([])` and a public `favourites = this._favourites.asReadonly()` can do the same job. The difference is who enforces the shape. | Concern | Hand-written signal service | NgRx SignalStore | |---|---|---| | Read-only public state | you remember `asReadonly()` per field | protected state by default | | Nested object fields | one signal per object, or manual splitting | `DeepSignal` per object literal slice | | Partial updates | a `set`/`update` call per signal | one `patchState` call with several slices | | Lifecycle logic | constructor plus `DestroyRef` by hand | `withHooks` with `onInit` / `onDestroy` | | Reuse across stores | inheritance or copy-paste | features composed into several stores | For a service with two fields the difference is small. For a team with many feature stores, the fixed structure means every store reads the same way. ## Where the store is provided `signalStore()` registers the class with **no** injector by default. You either: 1. pass a config object first, `signalStore({ providedIn: 'root' }, withState(...))`, for one instance shared by the whole app; or 2. list the store in a component's or route's `providers` array, which gives that component subtree its own instance. In the planner, the schedule store sits at root, while each session-detail component provides a small store of its own for the open session. ## Common misunderstandings - A SignalStore is **not** a module-level object you import and mutate; it is a class that Angular instantiates. - A plain SignalStore has **no actions or reducers**; methods change state directly through `patchState`. The global NgRx Store is a separate model, and the optional Events plugin is what adds a Flux-style layer to SignalStore. - Its state signals are typed **read-only** from outside; a `set()` call on them does not compile. - To type a variable or constructor parameter as the store, use `InstanceType<typeof ScheduleStore>`, because `ScheduleStore` itself is a class value, not a type. ## Functional definition versus a class Because `signalStore()` returns a class, a team can also write `export class ScheduleStore extends signalStore(...) { ... }` and add members in the class body. The NgRx docs allow this but recommend the functional style shown above: every member then comes from a feature, the order of features documents the dependencies, and protected state keeps working without extra configuration. Mixing both styles in one codebase makes stores harder to compare, so pick one and keep it.

  • If a SignalStore slice holds an object literal such as filter: { track, day }, how do you read filter.track as a signal?
    `withState` wraps object-literal slices in a `DeepSignal`: `store.filter()` returns the whole object, and `store.filter.track()` is a signal of its own, created lazily the first time you access it. Arrays and primitives stay plain signals. In NgRx 22 a union slice that contains an object literal gets a `DeepSignal` for each object member instead of one signal for the whole union.
  • How do you use a SignalStore as a type, for a function parameter or constructor injection?
    `signalStore()` returns a class value, so its instance type is `InstanceType<typeof ScheduleStore>`. The NgRx docs suggest exporting a type alias with the same name as the store; then `constructor(readonly store: ScheduleStore)` works as well as `inject(ScheduleStore)`.

saying these in an interview costs you the question

  • A SignalStore is an object you import and mutate directly, like a module-level variable.
  • signalStore() registers the store in the root injector automatically.
  • You update SignalStore state by calling set() on the state signals it exposes.
  • A SignalStore needs actions, reducers and effects just like the global NgRx Store.
  • Object slices in withState are one signal only; their fields cannot be read as signals.