AngularJSSignal-Based State

NgRx SignalStore — Signal-Based State

NgRx SignalStore (introduced in NgRx 17) is a lightweight, signal-native state management solution. It combines the structure and best practices of NgRx with Angular Signals, offering dramatically less boilerplate than the classic NgRx Store while still being composable and testable.

SignalStore is built on Angular Signals — there are no Observables, no actions, no reducers, and no effects classes. State is reactive by default.

NgRx Store vs SignalStore

Feature

NgRx Store

SignalStore

State primitive

Observable (BehaviorSubject)

Angular Signal

Actions required

Yes

No

Reducers required

Yes

No

Selectors

createSelector()

computed() signals

Side effects

createEffect() class

withMethods() + inject()

DevTools

Redux DevTools

NgRx Signals DevTools (beta)

Boilerplate

High

Low

Best for

Large complex apps

Modern Angular 17+ apps

Installation

Bash
npm install @ngrx/signals
Creating a Basic SignalStore

TS
// src/app/store/counter.store.ts
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

// Define the state interface
interface CounterState {
  count: number;
  step: number;
}

export const CounterStore = signalStore(
  { providedIn: 'root' },  // or provide per component/module

  // withState — define initial state
  withState<CounterState>({ count: 0, step: 1 }),

  // withComputed — derived signals (like selectors)
  withComputed(({ count, step }) => ({
    doubled: computed(() => count() * 2),
    canDecrement: computed(() => count() > 0),
    nextValue: computed(() => count() + step()),
  })),

  // withMethods — state mutations and side effects
  withMethods((store) => ({
    increment(): void {
      patchState(store, { count: store.count() + store.step() });
    },
    decrement(): void {
      if (store.count() > 0) {
        patchState(store, { count: store.count() - store.step() });
      }
    },
    reset(): void {
      patchState(store, { count: 0 });
    },
    setStep(step: number): void {
      patchState(store, { step });
    },
  }))
);
Note
patchState(store, partialState) is the NgRx SignalStore helper for immutably updating state — similar to BehaviorSubject.next().
Using the Store in a Component

TS
// src/app/counter/counter.component.ts
import { Component, inject } from '@angular/core';
import { CounterStore } from '../store/counter.store';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <h2>Count: {{ store.count() }}</h2>
    <p>Doubled: {{ store.doubled() }}</p>
    <p>Next: {{ store.nextValue() }}</p>

    <button (click)="store.decrement()" [disabled]="!store.canDecrement()">-</button>
    <button (click)="store.increment()">+</button>
    <button (click)="store.reset()">Reset</button>

    <label>
      Step:
      <input type="number" [value]="store.step()"
             (change)="store.setStep(+$event.target.value)" />
    </label>
  `,
})
export class CounterComponent {
  store = inject(CounterStore);
}
Tip
All signal store properties are regular Angular Signals — read them with () and they integrate with the template change detection automatically. No async pipe needed.
Practical Example: Products Store with HTTP

TS
// src/app/store/products.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { inject, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, catchError, EMPTY } from 'rxjs';
import { tapResponse } from '@ngrx/operators';

export interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

interface ProductsState {
  products: Product[];
  selectedId: number | null;
  loading: boolean;
  error: string | null;
  filter: string;
}

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  withState<ProductsState>({
    products: [],
    selectedId: null,
    loading: false,
    error: null,
    filter: '',
  }),

  withComputed(({ products, selectedId, filter }) => ({
    selectedProduct: computed(() =>
      products().find(p => p.id === selectedId()) ?? null
    ),
    filteredProducts: computed(() => {
      const term = filter().toLowerCase();
      return term
        ? products().filter(p => p.name.toLowerCase().includes(term))
        : products();
    }),
    productCount: computed(() => products().length),
  })),

  withMethods((store, http = inject(HttpClient)) => ({
    // Sync methods
    selectProduct(id: number): void {
      patchState(store, { selectedId: id });
    },
    setFilter(filter: string): void {
      patchState(store, { filter });
    },
    removeProduct(id: number): void {
      patchState(store, { products: store.products().filter(p => p.id !== id) });
    },

    // Async method using rxMethod
    loadProducts: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(() =>
          http.get<Product[]>('/api/products').pipe(
            tapResponse({
              next: (products) => patchState(store, { products, loading: false }),
              error: (err: Error) =>
                patchState(store, { error: err.message, loading: false }),
            })
          )
        )
      )
    ),

    // Async method — create product
    createProduct: rxMethod<Omit<Product, 'id'>>(
      pipe(
        switchMap((product) =>
          http.post<Product>('/api/products', product).pipe(
            tapResponse({
              next: (created) =>
                patchState(store, { products: [...store.products(), created] }),
              error: (err: Error) =>
                patchState(store, { error: err.message }),
            })
          )
        )
      )
    ),
  }))
);
Using the Products Store

TS
// src/app/products/products.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { ProductsStore } from '../store/products.store';

@Component({
  selector: 'app-products',
  standalone: true,
  imports: [FormsModule, CurrencyPipe],
  template: `
    @if (store.loading()) {
      <div class="loading-bar">Loading...</div>
    }

    @if (store.error(); as error) {
      <div class="error">{{ error }}</div>
    }

    <input
      type="text"
      placeholder="Filter products..."
      [ngModel]="store.filter()"
      (ngModelChange)="store.setFilter($event)"
    />

    <p>Showing {{ store.filteredProducts().length }} of {{ store.productCount() }}</p>

    @for (product of store.filteredProducts(); track product.id) {
      <div class="card" (click)="store.selectProduct(product.id)">
        <h3>{{ product.name }}</h3>
        <p>{{ product.price | currency }}</p>
        <button (click)="store.removeProduct(product.id); $event.stopPropagation()">
          Remove
        </button>
      </div>
    }

    @if (store.selectedProduct(); as p) {
      <aside>
        <h2>Selected: {{ p.name }}</h2>
        <p>{{ p.price | currency }} — {{ p.category }}</p>
      </aside>
    }
  `,
})
export class ProductsComponent implements OnInit {
  store = inject(ProductsStore);

  ngOnInit(): void {
    this.store.loadProducts();
  }
}
withHooks — Lifecycle Hooks

Use withHooks to run logic when the store is initialized or destroyed.

TS
import { signalStore, withState, withMethods, withHooks } from '@ngrx/signals';

export const AppStore = signalStore(
  { providedIn: 'root' },
  withState({ initialized: false }),
  withMethods((store, http = inject(HttpClient)) => ({
    load: rxMethod<void>(/* ... */),
  })),
  withHooks({
    onInit(store): void {
      console.log('Store initialized');
      store.load(); // auto-load when store is first injected
    },
    onDestroy(store): void {
      console.log('Store destroyed');
    },
  })
);
Component-Level Store (Not Singleton)

Provide the store at component level for state that should be destroyed with the component.

TS
// No 'providedIn: root' in the store
export const LocalStore = signalStore(
  withState({ /* ... */ }),
  withMethods(/* ... */)
);

// Provide in the component — each instance gets its own store
@Component({
  providers: [LocalStore], // <-- component-scoped
  template: '...',
})
export class MyComponent {
  store = inject(LocalStore);
}
Best Practices
  • Use withState() for all state definitions — never add plain properties to withMethods()

  • Derive computed values with withComputed() — never duplicate state

  • Use patchState() for all state updates — never mutate signal values directly

  • Use rxMethod() when methods need to handle Observables (HTTP calls)

  • Use withHooks() onInit to auto-load data when the store is first injected

  • Provide the store in root for app-wide state, in the component for local state

  • Keep each store focused on one domain (products, auth, cart) — separate files

Summary

NgRx SignalStore brings modern Angular Signals into the NgRx ecosystem, dramatically reducing boilerplate compared to classic NgRx Store. You get signal-based state, computed derivations, type-safe methods, and lifecycle hooks — all in a single signalStore() call. It's the recommended NgRx approach for new Angular 17+ applications that want structured state management without the ceremony of actions, reducers, and effects.