Skip to content
Angular ng state 4 min read

NgRx SignalStore

NgRx SignalStore is the modern, signal-native state management primitive from the NgRx team. Instead of actions, reducers, and observable selectors, it lets you declare state, derived values, and methods in one cohesive unit built entirely on Angular signals. The result is far less boilerplate than the classic Redux-style Store, while keeping the structure, testability, and tooling you expect from NgRx. For feature state in modern Angular apps, SignalStore is often the sweet spot.

Why SignalStore

A SignalStore is a tree-shakeable Angular service created with signalStore(). You compose it from small features: withState for the data, withComputed for derived values, and withMethods for the logic that updates state. Because everything is a signal, your components read state synchronously in templates with the () call syntax and the new control flow, with no async pipe and no manual subscriptions.

npm install @ngrx/signals

Defining a store

You declare the shape of your state and pass an initial value to withState. withComputed exposes Signal values derived from state, and withMethods returns the imperative API. Use patchState to apply immutable updates.

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

interface Todo {
  id: number;
  title: string;
  done: boolean;
}

interface TodosState {
  todos: Todo[];
  filter: 'all' | 'active' | 'done';
}

const initialState: TodosState = {
  todos: [],
  filter: 'all',
};

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ todos, filter }) => ({
    remaining: computed(() => todos().filter((t) => !t.done).length),
    visible: computed(() => {
      switch (filter()) {
        case 'active':
          return todos().filter((t) => !t.done);
        case 'done':
          return todos().filter((t) => t.done);
        default:
          return todos();
      }
    }),
  })),
  withMethods((store) => ({
    add(title: string): void {
      const todo: Todo = { id: Date.now(), title, done: false };
      patchState(store, { todos: [...store.todos(), todo] });
    },
    toggle(id: number): void {
      patchState(store, {
        todos: store.todos().map((t) =>
          t.id === id ? { ...t, done: !t.done } : t
        ),
      });
    },
    setFilter(filter: TodosState['filter']): void {
      patchState(store, { filter });
    },
  }))
);

The { providedIn: 'root' } config registers the store as a singleton, exactly like any other root-provided service. Drop it to scope the store to a component’s providers array instead.

Consuming the store in a component

Inject the store with inject() and read its signals directly in the template. Every property declared in withState and withComputed is exposed as a Signal.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TodosStore } from './todos.store';

@Component({
  selector: 'app-todos',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input #box (keyup.enter)="store.add(box.value); box.value = ''" />

    <p>{{ store.remaining() }} item(s) left</p>

    <ul>
      @for (todo of store.visible(); track todo.id) {
        <li (click)="store.toggle(todo.id)">
          @if (todo.done) { <s>{{ todo.title }}</s> } @else { {{ todo.title }} }
        </li>
      } @empty {
        <li>No todos yet</li>
      }
    </ul>
  `,
})
export class TodosComponent {
  protected readonly store = inject(TodosStore);
}

Because the store properties are signals, the template recomputes only the affected bindings when state changes — no zone-wide checks required, which pairs naturally with OnPush.

Output:

2 item(s) left
- Buy milk
- Write docs

Async logic with rxMethod

For side effects such as HTTP calls, the rxMethod operator (from @ngrx/signals/rxjs-interop) bridges signals and RxJS. It returns a reactive method you can call with a value, a signal, or an observable, and it manages subscriptions for you.

import { inject } from '@angular/core';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState<{ users: string[]; loading: boolean }>({ users: [], loading: false }),
  withMethods((store, http = inject(HttpClient)) => ({
    load: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() =>
          http.get<string[]>('/api/users').pipe(
            tap((users) => patchState(store, { users, loading: false }))
          )
        )
      )
    ),
  }))
);

Tip: rxMethod automatically unsubscribes when the store is destroyed. You never need an ngOnDestroy or takeUntilDestroyed for store-owned streams.

Entity management and lifecycle hooks

NgRx ships an entity plugin (@ngrx/signals/entities) with withEntities, setAllEntities, addEntity, updateEntity, and removeEntity for normalized collections. The withHooks feature lets you run logic on init and destroy.

import { signalStore, withHooks } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';

export const ProductsStore = signalStore(
  withEntities<{ id: number; name: string }>(),
  withHooks({
    onInit(store) {
      console.log('store ready', store.entities());
    },
  })
);

SignalStore vs the classic NgRx Store

AspectSignalStoreClassic Store
PrimitiveSignalsObservables
BoilerplateLow (one unit)High (actions/reducers/selectors)
Reading statestore.value()store.select(...) + async
AsyncrxMethodEffects
DevToolsOptional pluginBuilt in
Best forFeature/local stateLarge global, event-sourced state

Best Practices

  • Keep state immutable: always update through patchState, never mutate arrays or objects in place.
  • Split logic into small, named store features and compose them, rather than one giant withMethods.
  • Put derived data in withComputed instead of recomputing it in components or templates.
  • Use rxMethod for async work so subscription lifecycle is handled automatically.
  • Reach for withEntities whenever you manage collections keyed by an id.
  • Scope stores to a component’s providers for ephemeral feature state, and use { providedIn: 'root' } only for genuinely shared state.
Last updated June 14, 2026
Was this helpful?