Quick answer: NgRx is the Redux-style state library for Angular: state lives in one immutable store, changes happen through dispatched actions, reducers compute the next state, selectors read slices and effects handle side effects like HTTP calls. Start with plain services and signals, and adopt NgRx when multiple unrelated features share the same state, undo/redo or time-travel matters, or every new bug is “who changed this value?”. NgRx SignalStore is the lighter modern alternative inside the same family.
Table of Contents
Why NgRx exists
Every Angular application reaches the same point: a value lives in a service, three features write to it, and a bug appears that no single component can explain. NgRx answers that with an enforced discipline — one immutable store, all changes as dispatched actions, all reads through selectors. Because every state transition is a plain object describing what happened, the difficult question “how did the state get like this?” has a literal answer: read the action log. That audit trail is the real product of NgRx; the architecture around it exists to guarantee the trail stays complete.
NgRx is not free. Every piece of state gains an action type, a reducer branch and usually an effect, so small local state costs far more code under NgRx than a signal in a service. The honest decision rule: use signals and services while state is owned by one feature, and reach for NgRx when state is shared across features, when you need to replay or debug its history, or when the team is large enough that conventions scale better than local judgement. The signals and state management guide covers the pre-NgRx side of that decision.
The pattern has a cost curve worth stating plainly. Under NgRx, a new piece of shared state costs an action file, a reducer branch, a selector and usually an effect — perhaps six files for a value a signal service would hold in three lines. What you buy back appears later: the devtools log answers “who changed this” for every incident, replay reproduces a bug from its action sequence, and a new team member reads one convention instead of five. The official NgRx guide frames the same trade.
Store, actions and reducers: the core loop
The loop has exactly three participants. The store holds the immutable state tree. Actions are plain typed objects describing an event. Reducers are pure functions that take the current state and an action and return the next state — never mutated, always a new object for the slice that changed. NgRx composes feature reducers into one root reducer, so the store stays a single tree while each feature owns its branch.
export const addToCart = createAction(
'[Cart] Add Product',
props<{ product: Product }>(),
);
export const cartReducer = createReducer(
initialCartState,
on(addToCart, (state, { product }) => ({
...state,
items: [...state.items, product],
total: state.total + product.price,
})),
);
// dispatching from a component
this.store.dispatch(addToCart({ product }));
The naming convention — source then event, bracketed — is not decoration: NgRx DevTools groups and labels actions by it, and a session where one feature writes add and another writes Add is a session where the devtools log stops being readable. Keep reducers pure: no HTTP, no Date.now, no reading other branches. A reducer that needs those is not a reducer — the work belongs in an effect, where it stays testable and stays out of the replayable history that makes NgRx worth adopting.
Action shape is where most NgRx codebases drift. Keep payloads small and serializable — plain data only, no class instances, no raw response objects with circular references — because the devtools store serializes every action, and a payload that cannot round-trip silently breaks time-travel debugging. When an event carries context the reducer should not decide, put it in metadata rather than widening the payload, and let the effect own the decision.
The devtools tie the loop together. The extension records every action and state snapshot, so a bug report becomes a reproducible sequence: open the session, step back to the state before the wrong value appeared, and read which action introduced it. Teams that open the log weekly adopt naming discipline without being told to; teams that never open it drift within a month, which makes the extension part of the process rather than a debugging luxury.
Selectors and memoization
Selectors read slices of the store and memoize: given the same state reference, they return the same result reference without recomputing. That property is what keeps NgRx fast — a component subscribed to a selector re-renders only when the selected slice actually changes, not on every dispatched action. Compose selectors from smaller ones with createSelector, and keep the derived value’s identity stable, because reference equality is the contract that OnPush components and signals both rely on downstream.
const selectCartItems = (s: RootState) => s.cart.items;
const selectCartTotal = createSelector(selectCartItems, items =>
items.reduce((sum, i) => sum + i.price, 0),
);
// in the component: re-renders only when the total changes
readonly total = toSignal(this.store.select(selectCartTotal), { initialValue: 0 });
The classic mistake is building a fresh array or object inside a selector without createSelector, so every dispatch produces a “new” value and every subscriber re-renders forever. If a selector output is an object literal or an array built by filtering, wrap it with createSelector so memoization applies. The other common leak is a selector that reads a service or the clock — same purity rule as reducers: selectors compute from state and nothing else.
Selector composition replaces component-side logic. A selector that joins two feature slices — cart items with product prices, say — keeps that join memoized and testable, whereas the same join performed inside a component re-runs on every render and lives nowhere a test can reach. Build small selectors per slice, compose them into view selectors, and treat the selector layer as the API every component reads through; a component reaching past it for raw state is the smell that memoization is about to be bypassed.
Effects for side effects and API calls
Effects turn actions into other actions. They observe the action stream with createEffect, run the side effect — usually an HttpClient call — and map the result back into a success or failure action that the reducers understand. This is where the NgRx discipline pays off: the HTTP result enters state through the same replayable path as a button click, so a network response is never a hidden state change.
loadCart = createEffect(() => this.actions$.pipe(
ofType(CartApi.load),
exhaustMap(() => this.http.get<Cart>('/api/cart').pipe(
map(cart => CartApi.loadSuccess({ cart })),
catchError(err => of(CartApi.loadFailure({ error: err.message }))),
)),
), { dispatch: true });
// fail actions must exist: an effect that swallows errors hides state forever
loadCartFailure = createEffect(() => this.actions$.pipe(
ofType(CartApi.loadFailure),
tap(({ error }) => this.toast.error(error)),
), { dispatch: false });
Choosing the mapping operator is a real decision, not a preference: exhaustMap ignores new triggers while one request runs (good for load-once), switchMap cancels the previous request (good for search), mergeMap runs them all in parallel (good for batch jobs). The four mapping operators section of the RxJS operators guide covers the trade in depth; inside NgRx effects the same choice decides whether a stale response can overwrite a fresh one. Keep effects small, name the failure path every time, and never dispatch an action from a reducer — the loop only stays auditable if it runs one way.
Error handling deserves its own sentence because it is where streams die quietly. An effect whose inner observable throws terminates permanently — the next action of that type is never observed again — so catchError inside the inner pipe, mapped to a failure action, is not optional style but the difference between a recoverable error and a dead feature. The toast shown to the user is the visible half; the dispatched action is what keeps loading state honest.
Effect ordering is subtler than it looks. Two effects listening to the same action both fire, in registration order, and an effect that dispatches a follow-up action re-enters the stream before the next tick settles. That re-entry is how a small NgRx feature grows an infinite loop — load succeeds, refresh dispatches, refresh succeeds, load dispatches again — usually surfacing only in production where the request volume hides the first few cycles. The guard is structural: every effect that dispatches should name the action that stops it, and the reducer branch that handles that stop should be the last case in the file, so a reviewer can see the loop close without tracing RxJS by hand.
One boundary keeps NgRx honest: the store holds application state, not server cache. HttpClient caching, request deduplication and revalidation belong to the data layer — resolvers, guards or a query library — because they change per request rather than per user intent. When a store starts mirroring raw API responses it becomes a second cache with none of the first one’s invalidation rules, and every bug in it looks like a state bug.
Entity adapters and normalized state
Collections inside NgRx follow one shape: { ids: number[], entities: { [id]: T } } built by createEntityAdapter. Normalizing looks like overhead until the first update — with an adapter, editing one item of ten thousand is adapter.updateOne, a single replace in a map, instead of an array-wide map that reallocates and re-renders every row. The adapter also provides selectIds and selectEntities selectors, which compose cleanly with the memoization rules above.
export const ordersAdapter = createEntityAdapter<Order>({
selectId: o => o.id,
sortComparer: (a, b) => b.placedAt.localeCompare(a.placedAt),
});
const ordersState = ordersAdapter.getInitialState({ loading: false });
export const ordersReducer = createReducer(ordersState,
on(ordersLoaded, (s, { orders }) => ordersAdapter.setAll(orders, s)),
on(orderShipped, (s, { id }) =>
ordersAdapter.updateOne({ id, changes: { status: 'shipped' } }, s)),
);
Keep a single source of truth per entity too: if the same order appears in the orders list and in a detail view, both read the same adapter, not two copies. Duplicated entities are the NgRx version of the mutation bug — fix one, watch the other stay stale — and selectors that join two slices are the right place for the cross-feature views that made you adopt NgRx in the first place.
Adapters also standardize querying. selectAll with a projector gives sorted, filtered views computed once per state change instead of inside the component, and pagination selectors compose on top without touching the entities map. The habit to build early: never reach into the raw state tree from a component. A selector exists, or a selector gets written — direct tree access bypasses memoization and re-introduces exactly the scattered-reads problem NgRx was adopted to remove.
NgRx SignalStore: the modern option
NgRx SignalStore drops the action-reducer ceremony while keeping the discipline: state declared as signals, methods on the store mutate it through patch or custom writers, and no dispatcher or reducer files. It is the same library family, and it fits the case where you wanted NgRx’s single owner but not its full event log — particularly new applications already built on signals.
export const CartStore = signalStore(
{ providedIn: 'root' },
withState<CartState>({ items: [], total: 0 }),
withComputed(({ items }) => ({
count: computed(() => items().length),
})),
withMethods((store, cartApi = inject(CartApi)) => ({
async add(product: Product) {
patchState(store, { total: store.total() + product.price });
await cartApi.add(product);
},
})),
);
The trade is explicit: SignalStore has no built-in replayable action history. If the audit trail is why you chose NgRx, stay with the store; if the reason was only “too many services writing the same values”, SignalStore is lighter and faster to adopt. Teams often run both — NgRx for the domain with compliance-grade history, SignalStore for feature-local state — and keeping that boundary documented is what stops the codebase from inventing a third way next quarter.
SignalStore changed what a new NgRx project looks like: most start there because the ceremony is lower, and graduate a slice to the full store only when the action log becomes genuinely valuable — compliance workflows, undoable operations, cross-team shared state. The graduation is smooth because the state shape carries over; what changes is that every mutation becomes a dispatched event with a history attached, which is exactly what the earlier sections were built to provide.
Testing NgRx: reducers, selectors, effects
The purity of the pieces is what makes NgRx unusually testable. A reducer test is a function call: give it state and an action, assert the returned state, done. A selector test is the same shape. Effects are the only part needing TestBed, and with provideMockActions the test controls the action stream exactly, so HTTP is mocked at the HttpClient layer rather than through timing.
it('adds a product and updates the total', () => {
const state = cartReducer(initialCartState, addToCart({ product: shoe }));
expect(state.items).toHaveLength(1);
expect(state.total).toBe(shoe.price);
});
it('loads the cart once', done => {
const actions = of(CartApi.load);
loadCartEffect = createEffect(() => actions, { dispatch: false });
// ... provideMockActions(() => actions), assert dispatched success
});
The two tests that catch real regressions are the failure path and the stale-response path: an effect without a failure action silently strands loading state forever, and a wrong mapping operator lets an older response win. Write both deliberately, and keep the reducer suite exhaustive over actions — the reducer is the one place where an untested branch is a state corruption waiting for a rare click. The testing and debugging guide covers the surrounding TestBed patterns.
The suite shape for NgRx is deliberately flat: dozens of reducer and selector specs per effect spec, because the reducers hold the business rules and cost milliseconds to run. Add one integration test per feature — dispatch a sequence, assert the final state through the store — and the suite catches a wrong reducer and a missed wiring in the same run. Keep fixtures shared so an action’s expected payload stays consistent across files.
Mocking follows the same seams. Mock HttpClient, not the effect: the effect is the unit under test, and replacing it with a stub only proves the stub was called. provideMockActions controls timing so a stream that should complete does, and a test asserting dispatched actions in order documents the effect’s contract better than any comment could.
NgRx earns its complexity only when the state it governs is genuinely shared or genuinely auditable. Revisit that judgement once a year: a store whose actions are mostly setLocalFlag is a signal store wearing the wrong clothes, and the migration in either direction is mechanical. What must never change is the discipline underneath — one owner for each value, updates as events, reads through selectors — because that is the part that fixes the bug NgRx was adopted to stop.
To learn Angular properly with a mentor, Ampersand Academy teaches web development one-to-one.
New to Angular? Build the fundamentals first in the free 14-part Angular course, then come back to this guide for the deeper material.
Store shape demands its own convention: keep domain state in one branch and UI state in another. Domain state – cart contents, user permissions, document revisions – survives navigation and belongs in the NgRx store. UI state – whether a modal is open, the current tab index, a filter string – often belongs in a component input or a local signal. Mixing the two fills the store with values that change on every click and makes the action log harder to read, which defeats one of the reasons NgRx was adopted in the first place.
A migration path that survives production: start with services holding plain signals for feature-local state, promote shared slices to NgRx whenever the same value appears in three or more features, and only then introduce effects for HTTP. Teams that skip the middle step end up with reducers for values that never needed to be shared, and effects that duplicate what a service already did locally. The boundary between local and shared is the boundary between a signal store and a full NgRx setup – draw it at the feature boundary, and the rest follows.
Frequently asked questions
Should I use NgRx or signals for state management?
Use signals in services while a feature owns its state. Use NgRx when state is shared across features, when you need an auditable history of changes, or when team size makes conventions safer than local judgement.
What is the difference between NgRx Store and SignalStore?
Store uses dispatched actions and pure reducers, giving a replayable event log. SignalStore keeps state in signals with direct methods — lighter and faster to write, without the action history.
Do reducers ever call HTTP APIs?
Never. Reducers are pure functions of state and action. HTTP belongs in effects, where success and failure come back into state as actions and stay part of the replayable history.
Why does my selector re-run on every action?
It is probably not wrapped in createSelector, so it recomputes and returns a new object reference every time. Memoized selectors keep reference equality and stop unnecessary re-renders.
Which RxJS operator should an effect use?
exhaustMap for load-once triggers, switchMap when the latest request should cancel the previous one such as search, mergeMap for parallel batch work. The choice decides whether a stale response can win.

