NgRx Signal Store — Modern State Management with Signals

NgRx SignalStore (introduced in NgRx 17) combines NgRx’s structured state management with Angular Signals — providing type-safe, reactive state without RxJS Observables. It uses a composable, functional API: withState() for state definition, withComputed() for derived signals, withMethods() for mutations and async operations, and withHooks() for lifecycle. For most Angular 18 projects, NgRx SignalStore strikes the right balance between structure (explicit state, testable methods) and simplicity (no actions/reducers boilerplate).

NgRx SignalStore

// npm install @ngrx/signals

import { signalStore, withState, withComputed, withMethods,
         withHooks, patchState }    from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity,
         removeEntity, updateEntity } from '@ngrx/signals/entities';
import { computed, inject }          from '@angular/core';

// ── PostsStore — using SignalStore ────────────────────────────────────────
export const PostsStore = signalStore(
  { providedIn: 'root' },   // or omit for component-level

  // ── State ──────────────────────────────────────────────────────────────
  withEntities<PostSummaryDto>(),  // adds entities, ids, entityMap signals
  withState({
    loading:  false,
    error:    null as string | null,
    total:    0,
    page:     1,
    query:    '',
  }),

  // ── Computed — derived signals ─────────────────────────────────────────
  withComputed(({ entities, loading, error, total }) => ({
    publishedPosts: computed(() => entities().filter(p => p.isPublished)),
    isEmpty:        computed(() => !loading() && entities().length === 0),
    hasError:       computed(() => error() !== null),
    hasMore:        computed(() => entities().length < total()),
  })),

  // ── Methods — mutations and async operations ───────────────────────────
  withMethods((store, api = inject(PostsApiService)) => ({
    // Sync: patch state directly
    setPage(page: number): void {
      patchState(store, { page });
    },

    setQuery(query: string): void {
      patchState(store, { query, page: 1 });
    },

    // Async: inline HTTP, patchState on result
    async loadPosts(): Promise<void> {
      patchState(store, { loading: true, error: null });
      try {
        const result = await firstValueFrom(
          api.getPublished(store.page(), 10, store.query())
        );
        patchState(store,
          setAllEntities(result.items, { idKey: 'id' }),
          { loading: false, total: result.total }
        );
      } catch (err: any) {
        patchState(store, { loading: false, error: err.message });
      }
    },

    async deletePost(id: number): Promise<void> {
      patchState(store, removeEntity(id));   // optimistic
      try {
        await firstValueFrom(api.delete(id));
        patchState(store, { total: store.total() - 1 });
      } catch {
        // rollback — reload
        this.loadPosts();
      }
    },
  })),

  // ── Lifecycle hooks ────────────────────────────────────────────────────
  withHooks({
    onInit(store) {
      // Load on store initialisation
      store.loadPosts();
    },
  }),
);

// ── Type inference — TypeScript knows the full shape ──────────────────────
type PostsStoreType = InstanceType<typeof PostsStore>;
// postsStore.entities()     → PostSummaryDto[]
// postsStore.loading()      → boolean
// postsStore.publishedPosts() → PostSummaryDto[]
// postsStore.loadPosts()    → Promise<void>

// ── Component using SignalStore ────────────────────────────────────────────
@Component({
  selector:   'app-post-list',
  standalone:  true,
  template: `
    @if (store.loading()) { <app-spinner /> }
    @for (post of store.entities(); track post.id) {
      <app-post-card [post]="post" />
    } @empty {
      @if (!store.loading()) { <p>No posts found.</p> }
    }
  `,
})
export class PostListComponent {
  protected store = inject(PostsStore);
}
Note: patchState(store, ...) is the SignalStore equivalent of a reducer — it merges the provided partial state into the current state. Unlike Redux reducers (which replace the entire state), patchState only updates the specified keys. The entity helper functions (setAllEntities, addEntity, removeEntity, updateEntity) are used as arguments to patchState to update the normalised entity collection, equivalent to NgRx Entity adapter methods in traditional NgRx.
Tip: NgRx SignalStore is the most approachable NgRx primitive for Angular 18 teams. It provides the structure benefits of NgRx (explicit state interface, testable methods, computed derivations) with Signal ergonomics (no Observables, no subscriptions, automatic change detection). For new Angular 18 projects, start with SignalStore rather than traditional NgRx Store + Effects + Selectors — it achieves 90% of the same benefits with 50% of the boilerplate. Migrate to global NgRx Store only if cross-store state interactions or Redux DevTools time-travel become necessary.
Warning: SignalStore methods are regular functions — they can be called from templates and components without special ceremony. But because they are synchronous references to the store, they cannot be easily tree-shaken if unused. For large applications, consider splitting the store into feature stores (withMethods in separate files via store composition) to improve build-time tree shaking and keep individual store files manageable.

NgRx Primitive Decision Guide

Primitive Scope Best For
Service + Signals App Simple shared state, small apps
ComponentStore Component Complex component state, RxJS-heavy
SignalStore App or Component Structured state, Angular 18 preferred
NgRx Store + Effects App (global) Complex cross-feature, large teams, DevTools

Common Mistakes

Mistake 1 — Using mutable operations inside patchState (breaks Signal reactivity)

❌ Wrong — patchState(store, { entities: store.entities().push(post) }); push mutates array in place.

✅ Correct — use SignalStore entity helpers: patchState(store, addEntity(post)).

Mistake 2 — Providing SignalStore at root for per-component state

❌ Wrong — signalStore({ providedIn: 'root' }) for state that should be per-component instance.

✅ Correct — omit providedIn and add to providers: [] on the component for component-scoped stores.

🧠 Test Yourself

A SignalStore uses withEntities<PostSummaryDto>() and withComputed adds publishedPosts: computed(() => entities().filter(...)). When an unrelated loading state changes, does publishedPosts recompute?