NgRx SignalStore
A single signalStore owns the account list, the search query, and the load status. The KPIs and the grid below are computed signals derived from that state, the search box and reload button call store methods, and the initial fetch runs through an rxMethod — no component-level state, no boilerplate. All data is synthetic and seeded, so it's identical on the server and after hydration.
SignalStore vs classic NgRx
This page and the Classic NgRx page build the same kind of feature two ways. Neither is the “right” answer — they trade ceremony for traceability, and which side of that trade you want depends on the feature in front of you.
| Dimension | SignalStore | Classic NgRx |
|---|---|---|
| Where state lives | One signalStore — state, query, and status in a single object you inject. | A reducer-owned slice in the global store, reached through the feature key. |
| Ceremony | Almost none. The store is the feature; the component just reads it. | Actions, reducer, effects, and selectors — four files before a row renders. |
| Read & derive | computed signals on the store; the KPIs are just fields you read. | Memoized selectors composed from the slice, consumed via selectSignal. |
| Async work | rxMethod — an RxJS pipeline that lives on the store as a method. | An effect that listens for an action and dispatches the success action back. |
| Traceability | Method calls. You read the store to know what happened — no event log. | A serializable action stream you can replay and time-travel in the Redux DevTools. |
| Reach for it when… | The feature is self-contained and you want to ship without the boilerplate tax. | Many parts react to the same events and you need the audit trail to debug them. |
View source — the same feature, both ways
One feature, two state styles. On the left a signalStore folds state, derivation, and async into one injectable object; on the right the same shape of feature is spread across the classic action → reducer → effect → selector pipeline.
import { computed } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { map, pipe, switchMap, tap, timer } from 'rxjs';
import { generateAccounts, type Account } from '@foo/util-data';
/*
* A status enum, not a loose `boolean loading`. Three named states read better at the
* call site than two booleans you have to mentally combine, and they make the impossible
* states impossible: you can't be both loading and loaded at once.
*/
export type AccountsStatus = 'idle' | 'loading' | 'loaded';
/*
* Constraining the sort key to real Account fields means a typo is a compile error, not a
* silent no-op at runtime. The type is doing the guarding so you don't have to.
*/
export type AccountSortKey = 'name' | 'type' | 'balance' | 'status';
/*
* This is the whole source of truth — four fields, nothing derived. Everything the UI
* shows (the filtered list, the totals, the counts) is computed from these below, never
* stored alongside them. Source state and derived state live in different places on purpose.
*/
export interface AccountsState {
accounts: Account[];
query: string;
sortKey: AccountSortKey;
status: AccountsStatus;
}
const initialState: AccountsState = {
accounts: [],
query: '',
sortKey: 'name',
status: 'idle',
};
/*
* One comparator for every column. The trap with generic sorting is treating numbers like
* strings — "100" sorts before "20" alphabetically, which looks like a bug to anyone reading
* a balance column. So we branch: numeric fields subtract, everything else uses
* `localeCompare` (which also handles case and accents the way a human expects).
*/
function compareAccounts(a: Account, b: Account, key: AccountSortKey): number {
const av = a[key];
const bv = b[key];
if (typeof av === 'number' && typeof bv === 'number') {
return av - bv;
}
return String(av).localeCompare(String(bv));
}
/*
* This is the case for SignalStore in one object. State, the values you derive from it,
* and the methods that change it all live in the same declaration — `withState`,
* `withComputed`, `withMethods`, `withHooks`. Colocation is the point: when the data, the
* derivations, and the mutations sit together, you read a feature top to bottom instead of
* chasing it across an actions file, a reducer, an effects file, and a selectors file.
* Compare this to the classic-NgRx transactions feature next door — same job, four files.
*
* A component never reaches in and reassigns state. It calls a method, the method calls
* `patchState`, and every `computed` downstream re-runs on its own. That one-way flow is
* what keeps the store predictable: there is exactly one way state changes, and it's named.
*
* `providedIn: 'root'` makes this a singleton, so every component that injects it sees the
* same accounts — no syncing, no prop-drilling. The `onInit` hook fires the first load the
* moment the store comes alive, so the UI never has to remember to kick it off.
*/
export const AccountsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ accounts, query, sortKey }) => ({
/*
* The list the table actually renders, derived from the raw accounts plus the query and
* sort key. Because it's a `computed`, it only recalculates when one of those three
* signals changes, and it caches the result until then — so binding it in a template
* doesn't re-filter and re-sort on every change-detection pass. Note what we are NOT
* doing: storing a second `filteredAccounts` array and keeping it in sync by hand. That
* hand-syncing is where "the count says 12 but I see 9 rows" bugs come from.
*/
filtered: computed(() => {
const q = query().trim().toLowerCase();
const key = sortKey();
const matched = q
? accounts().filter((acc) =>
[acc.name, acc.type, acc.status, acc.region]
.join(' ')
.toLowerCase()
.includes(q),
)
: accounts();
return [...matched].sort((a, b) => compareAccounts(a, b, key));
}),
/*
* Deliberately summed off the full `accounts`, not `filtered` — this is the total
* portfolio balance, which shouldn't change just because someone typed in the search box.
* Where a derived value reads from is a real decision; getting it wrong here would make
* the headline number flicker as the user filters.
*/
totalBalance: computed(() =>
accounts().reduce((sum, acc) => sum + acc.balance, 0),
),
/*
* Small derivations earn their own signals too. A template that needs the count binds
* `count` instead of `accounts().length`, which keeps the markup declarative and means
* the consuming component never imports the array just to measure it.
*/
count: computed(() => accounts().length),
activeCount: computed(
() => accounts().filter((acc) => acc.status === 'active').length,
),
})),
withMethods((store) => ({
/*
* Methods are the only doorway into state. `patchState` does a shallow merge — you hand
* it just the keys that changed and it produces a new state object, leaving the rest
* untouched. You never mutate in place, so the signals it feeds can trust referential
* change to mean "something actually changed." These two setters are one line each, and
* that brevity is the SignalStore selling point: no action creator, no reducer case, no
* dispatch — name the change, patch it, done.
*/
setQuery(query: string): void {
patchState(store, { query });
},
setSort(sortKey: AccountSortKey): void {
patchState(store, { sortKey });
},
/*
* The async path. `rxMethod` lets a method own an RxJS pipeline, which is what you want
* the moment "load" stops being a single assignment and becomes a sequence with timing
* and cancellation. The real fetch is faked, but the shape is honest: flip to `loading`,
* do the async work, patch the result and flip to `loaded`.
*
* `timer(400)` instead of a random delay, and a seeded generator instead of live data,
* are both for determinism — the same inputs produce the same output on the server, in a
* test, and in the browser. SSR-safe and trivially testable fall out of that one choice.
*/
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { status: 'loading' })),
/*
* `switchMap` here is cheap insurance: if `load()` is called again before the first
* load resolves, the in-flight timer is cancelled and only the latest wins. A reload
* button mashed twice can never patch stale accounts in after the fresh ones.
*/
switchMap(() =>
timer(400).pipe(
map(() => generateAccounts(28)),
tap((accounts) =>
patchState(store, { accounts, status: 'loaded' }),
),
),
),
),
),
})),
withHooks({
onInit(store) {
store.load();
},
}),
);
/*
* `signalStore(...)` returns a class; this names its instance type so a test or component
* can write `inject(AccountsStore)` and get full typing on every signal and method. Reusing
* the same identifier for the value and its type is a deliberate ergonomic — one import, and
* it reads naturally at the injection site.
*/
export type AccountsStore = InstanceType<typeof AccountsStore>;
Accounts
Loading accounts…
View sourceaccounts.store.ts
import { computed } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { map, pipe, switchMap, tap, timer } from 'rxjs';
import { generateAccounts, type Account } from '@foo/util-data';
/*
* A status enum, not a loose `boolean loading`. Three named states read better at the
* call site than two booleans you have to mentally combine, and they make the impossible
* states impossible: you can't be both loading and loaded at once.
*/
export type AccountsStatus = 'idle' | 'loading' | 'loaded';
/*
* Constraining the sort key to real Account fields means a typo is a compile error, not a
* silent no-op at runtime. The type is doing the guarding so you don't have to.
*/
export type AccountSortKey = 'name' | 'type' | 'balance' | 'status';
/*
* This is the whole source of truth — four fields, nothing derived. Everything the UI
* shows (the filtered list, the totals, the counts) is computed from these below, never
* stored alongside them. Source state and derived state live in different places on purpose.
*/
export interface AccountsState {
accounts: Account[];
query: string;
sortKey: AccountSortKey;
status: AccountsStatus;
}
const initialState: AccountsState = {
accounts: [],
query: '',
sortKey: 'name',
status: 'idle',
};
/*
* One comparator for every column. The trap with generic sorting is treating numbers like
* strings — "100" sorts before "20" alphabetically, which looks like a bug to anyone reading
* a balance column. So we branch: numeric fields subtract, everything else uses
* `localeCompare` (which also handles case and accents the way a human expects).
*/
function compareAccounts(a: Account, b: Account, key: AccountSortKey): number {
const av = a[key];
const bv = b[key];
if (typeof av === 'number' && typeof bv === 'number') {
return av - bv;
}
return String(av).localeCompare(String(bv));
}
/*
* This is the case for SignalStore in one object. State, the values you derive from it,
* and the methods that change it all live in the same declaration — `withState`,
* `withComputed`, `withMethods`, `withHooks`. Colocation is the point: when the data, the
* derivations, and the mutations sit together, you read a feature top to bottom instead of
* chasing it across an actions file, a reducer, an effects file, and a selectors file.
* Compare this to the classic-NgRx transactions feature next door — same job, four files.
*
* A component never reaches in and reassigns state. It calls a method, the method calls
* `patchState`, and every `computed` downstream re-runs on its own. That one-way flow is
* what keeps the store predictable: there is exactly one way state changes, and it's named.
*
* `providedIn: 'root'` makes this a singleton, so every component that injects it sees the
* same accounts — no syncing, no prop-drilling. The `onInit` hook fires the first load the
* moment the store comes alive, so the UI never has to remember to kick it off.
*/
export const AccountsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ accounts, query, sortKey }) => ({
/*
* The list the table actually renders, derived from the raw accounts plus the query and
* sort key. Because it's a `computed`, it only recalculates when one of those three
* signals changes, and it caches the result until then — so binding it in a template
* doesn't re-filter and re-sort on every change-detection pass. Note what we are NOT
* doing: storing a second `filteredAccounts` array and keeping it in sync by hand. That
* hand-syncing is where "the count says 12 but I see 9 rows" bugs come from.
*/
filtered: computed(() => {
const q = query().trim().toLowerCase();
const key = sortKey();
const matched = q
? accounts().filter((acc) =>
[acc.name, acc.type, acc.status, acc.region]
.join(' ')
.toLowerCase()
.includes(q),
)
: accounts();
return [...matched].sort((a, b) => compareAccounts(a, b, key));
}),
/*
* Deliberately summed off the full `accounts`, not `filtered` — this is the total
* portfolio balance, which shouldn't change just because someone typed in the search box.
* Where a derived value reads from is a real decision; getting it wrong here would make
* the headline number flicker as the user filters.
*/
totalBalance: computed(() =>
accounts().reduce((sum, acc) => sum + acc.balance, 0),
),
/*
* Small derivations earn their own signals too. A template that needs the count binds
* `count` instead of `accounts().length`, which keeps the markup declarative and means
* the consuming component never imports the array just to measure it.
*/
count: computed(() => accounts().length),
activeCount: computed(
() => accounts().filter((acc) => acc.status === 'active').length,
),
})),
withMethods((store) => ({
/*
* Methods are the only doorway into state. `patchState` does a shallow merge — you hand
* it just the keys that changed and it produces a new state object, leaving the rest
* untouched. You never mutate in place, so the signals it feeds can trust referential
* change to mean "something actually changed." These two setters are one line each, and
* that brevity is the SignalStore selling point: no action creator, no reducer case, no
* dispatch — name the change, patch it, done.
*/
setQuery(query: string): void {
patchState(store, { query });
},
setSort(sortKey: AccountSortKey): void {
patchState(store, { sortKey });
},
/*
* The async path. `rxMethod` lets a method own an RxJS pipeline, which is what you want
* the moment "load" stops being a single assignment and becomes a sequence with timing
* and cancellation. The real fetch is faked, but the shape is honest: flip to `loading`,
* do the async work, patch the result and flip to `loaded`.
*
* `timer(400)` instead of a random delay, and a seeded generator instead of live data,
* are both for determinism — the same inputs produce the same output on the server, in a
* test, and in the browser. SSR-safe and trivially testable fall out of that one choice.
*/
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { status: 'loading' })),
/*
* `switchMap` here is cheap insurance: if `load()` is called again before the first
* load resolves, the in-flight timer is cancelled and only the latest wins. A reload
* button mashed twice can never patch stale accounts in after the fresh ones.
*/
switchMap(() =>
timer(400).pipe(
map(() => generateAccounts(28)),
tap((accounts) =>
patchState(store, { accounts, status: 'loaded' }),
),
),
),
),
),
})),
withHooks({
onInit(store) {
store.load();
},
}),
);
/*
* `signalStore(...)` returns a class; this names its instance type so a test or component
* can write `inject(AccountsStore)` and get full typing on every signal and method. Reusing
* the same identifier for the value and its type is a deliberate ergonomic — one import, and
* it reads naturally at the injection site.
*/
export type AccountsStore = InstanceType<typeof AccountsStore>;