Angular – State Management

August 5, 20264 min readUpdated 8/21/2026

Most Angular applications do not need a state library, and the ones that do should be able to say why. The pizza app splits the question down the middle, deliberately: signal services for the customer screens, NgRx for the admin section.

Level 1 — component state

State used by one component stays in it. Which modal is open, what is typed in a field, which tab is selected:

/** `null` means the builder is closed. One piece of state, not two. */
readonly selectedProduct = signal<Product | null>(null);

Moving this anywhere else buys nothing and costs indirection. Even the admin screens, which have a store available, keep their modal and form state in component signals.

Level 2 — a service holding signals

State shared by several components goes in a root-provided service:

@Injectable({ providedIn: 'root' })
export class CartService {
readonly items = this._items.asReadonly();
readonly orderType = this._orderType.asReadonly();
readonly hydrated = this._hydrated.asReadonly();

Private writable signals, public read-only views, and every change through a method. That is a complete state-management solution in about thirty lines, and it covers the entire customer-facing half of this application: auth, menu, cart, toasts.

This is the level most applications should stop at. It gives shared state, derived values, and a single place where the rules live. What it does not give is a transaction log of what happened — and that, not "shared state", is what a store is actually for.

Level 3 — NgRx

export const catalogFeature = createFeature({

The admin section uses four features: catalog (products, toppings and crusts — one domain, one feature), orders, reports, users.

Feature boundaries follow the domain, not the screen. Products, toppings and crusts get one feature between them even though they have a tab each, because they are one thing that changes together: adding a topping should be visible to the product editor without either page knowing the other exists.

NgRx keeps actions, reducer and effects as three visible things. Redux Toolkit's createSlice generates the actions from the reducer names and hides the wiring — more ceremony here, and a shorter path from a bug to the line that caused it.

⚠️ NgRx does not use Immer

This is the trap when moving from Redux Toolkit. state.items.push(x) in an RTK reducer is a draft write that Immer turns into a new object. In NgRx it is a real mutation — a genuine bug. Every branch of an NgRx reducer returns a new object.

Knowing whether a dispatch worked

A component dispatches and gets nothing back. RTK answers this with .unwrap(); NgRx has no equivalent, so the app awaits the success or failure actionstore/outcome.ts. It has its own ordering trap: subscribe before dispatching, or a synchronous failure is emitted before anyone is listening.

⚠️ An error is not serialisable

An ApiError does not survive a trip through a store — the class is lost and with it fieldErrors(). It is flattened to {message, fieldErrors} before becoming an action. The React app hits precisely the same wall with RTK.

Where the store is provided

This is the decision the whole app is arranged around:

return makeEnvironmentProviders([
  provideState(catalogFeature),
  provideState(ordersFeature),
  provideState(reportsFeature),
  provideState(usersFeature),
  provideEffects(catalogEffects, ordersEffects, reportsEffects, usersEffects),
]);

Those are attached to the /admin route rather than to app.config.ts, which does two jobs. Architecturally, the customer pages cannot reach this state even by accident — the split is enforced by the injector rather than by everyone remembering it. Practically, /admin is lazy, so every reducer, selector, effect and action string arrives only when an admin opens it.

⚠️ What will not move

provideStore(),

provideStore() has to be at the root. Put it on the route with the features and the app compiles, serves, and then dies on the first admin navigation with NG0201: No provider found for _Store, thrown from EffectsRunner_Factory. EffectsRunner is providedIn: 'root' and injects the Store, so it resolves from the root injector, where a route-provided store does not exist. Nothing fails at build time.

The honest accounting: that root provideStore() costs the entry bundle 15.9 kB raw, 4.4 kB over the wire — measured by building with and without it. Everything admin-specific stays lazy. React's version, where <Provider> inside a lazy AdminLayout keeps every last byte out of the entry bundle, is the cleaner split, and Angular cannot quite match it here.

makeEnvironmentProviders, not an array

⚠️ Returning a plain array of EnvironmentProviders and spreading it into a route's providers looks equivalent and is not: the nested array does not register, and the first component to inject(Store) fails with the same NG0201 at runtime.

Choosing

Start at level 1. Move to level 2 when a second component needs the same state — which is a service and some signals, and is where most applications should stay.

Reach for level 3 when you want the things a store actually provides: a serialisable record of every change, time-travel debugging, effects as declarative pipelines, and a shape that stays legible when a dozen people work on it. "Our state is complicated" is not the trigger. "We cannot tell what changed this, or in what order" is.

What is next

Testing all of the above.