Angular State Management in 2026: Signals, Services or NgRx? [2026]

Link copied
Angular State Management in 2026: Signals, Services or NgRx? [2026]

Angular State Management in 2026: Signals, Services or NgRx? [2026]

Every Angular team eventually has the state management argument. Someone wants NgRx because the last project used it; someone else says signals made libraries unnecessary. Both are partly right. Modern Angular gives you enough reactivity in the framework to build most apps without a state library, and there are still apps where a library pays for itself many times over. The useful question isn't "which library?" but "what problem do I actually have, and what's the smallest tool that solves it?"

This is lesson 14.1 of the Angular Tutorial, and the first lesson of Module 14, Ecosystem & Architecture. It follows lesson 13.4, which closed the animations module. You'll learn the four levels of state management available to an Angular app in 2026, what each one costs and buys you, a complete no-library store built from a service and signals, and a decision table for choosing between them.

The four levels #

State management in Angular is a ladder. Each step adds structure and tooling, and also more code and more concepts for the team to learn:

Level What it is Good for Cost
1. Local signals signal() and computed() inside a component State that one component (and its children through inputs) uses None
2. Service with signals An @Injectable that owns private signals and exposes read-only views and methods State shared across routes or distant components A small amount of convention
3. NgRx Signal Store signalStore() from @ngrx/signals, composed from features Many shared stores that should look the same; entities; reusable patterns One dependency, a small API
4. NgRx Store Redux pattern: actions, reducers, selectors, effects Large apps that want an event log, strict one-way flow and time-travel debugging The most code and concepts

Two rules keep you on the right step. First, start low and climb only when it hurts: moving state up a level later is usually a mechanical refactor. Second, levels mix: a large app can use NgRx Store for a few global concerns, Signal Stores for features and plain signals inside components, all at the same time.

Level 1: local signals #

Most state is local. Whether a dropdown is open, the current tab, the text in a search box, a form's draft values: none of that belongs in a shared store.

@Component({
  selector: 'app-order-filters',
  template: `
    <input [value]="query()" (input)="query.set($any($event.target).value)" />
    <p>{{ activeCount() }} filters active</p>
  `,
})
export class OrderFilters {
  query = signal('');
  status = signal<'all' | 'open' | 'shipped'>('all');
  activeCount = computed(() => (this.query() ? 1 : 0) + (this.status() !== 'all' ? 1 : 0));
}

Local state disappears with the component, which is exactly what you want. If a parent needs it, pass it down with input() and send changes up with output() or model() (lessons 2.4, 2.5 and 3.3). Reach for level 2 when the same state is needed by components that don't share a convenient parent, or when it must survive navigation.

Level 2: a service holding signals #

This is the no-library baseline, and it covers more apps than people expect. The pattern has four parts: private writable signals, public read-only signals, derived state with computed(), and methods that are the only way to change state.

import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

export interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartStore {
  private http = inject(HttpClient);

  // 1. private, writable state
  private readonly _items = signal<CartItem[]>([]);
  private readonly _saving = signal(false);
  private readonly _error = signal<string | null>(null);

  // 2. public, read-only views
  readonly items = this._items.asReadonly();
  readonly saving = this._saving.asReadonly();
  readonly error = this._error.asReadonly();

  // 3. derived state
  readonly count = computed(() => this._items().reduce((n, i) => n + i.quantity, 0));
  readonly total = computed(() => this._items().reduce((s, i) => s + i.price * i.quantity, 0));

  // 4. the only ways to change state
  add(item: Omit<CartItem, 'quantity'>): void {
    this._items.update(items => {
      const existing = items.find(i => i.productId === item.productId);
      return existing
        ? items.map(i => (i === existing ? { ...i, quantity: i.quantity + 1 } : i))
        : [...items, { ...item, quantity: 1 }];
    });
  }

  remove(productId: string): void {
    this._items.update(items => items.filter(i => i.productId !== productId));
  }

  async checkout(): Promise<void> {
    this._saving.set(true);
    this._error.set(null);
    try {
      await firstValueFrom(this.http.post('/api/orders', { items: this._items() }));
      this._items.set([]);
    } catch {
      this._error.set('Checkout failed. Please try again.');
    } finally {
      this._saving.set(false);
    }
  }
}

Components inject it and read signals directly in templates:

@Component({
  selector: 'app-cart-badge',
  template: `<a routerLink="/cart">Cart ({{ cart.count() }})</a>`,
  imports: [RouterLink],
})
export class CartBadge {
  protected cart = inject(CartStore);
}

Because templates read signals, every component that shows cart data updates when, and only when, the cart changes. No subscriptions, no manual change detection.

The pattern's weak points show up at scale. Every service invents its own shape for loading and error flags. Entity collections (lists keyed by id) get re-implemented with slightly different update logic each time. Nothing stops a developer from adding a public set() method that bypasses your rules. And there's no built-in way to see a history of state changes. If those problems start appearing across many services, a library starts to make sense. For server data specifically, also look at resource() and httpResource() (lesson 6.3), which already model loading, error and value for you.

Level 3: NgRx Signal Store #

@ngrx/signals keeps the spirit of the service-with-signals pattern but gives it a fixed, composable shape. The cart above becomes:

import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as CartItem[], saving: false }),
  withComputed(({ items }) => ({
    count: computed(() => items().reduce((n, i) => n + i.quantity, 0)),
  })),
  withMethods(store => ({
    remove(productId: string): void {
      patchState(store, ({ items }) => ({ items: items.filter(i => i.productId !== productId) }));
    },
  })),
);

What you gain: state is protected by default (only the store's own methods can call patchState on it), every state slice becomes a signal automatically, entity collections come ready-made via withEntities, RxJS-based side effects get rxMethod, and common behaviour (loading flags, persistence, logging) can be packaged once with signalStoreFeature and reused across stores. A store can live at the root or be provided per component, so it also works for complex screen-level state. Lesson 14.2 covers all of this in depth.

Level 4: NgRx Store #

The classic @ngrx/store implements the Redux pattern. Components dispatch actions that describe what happened ([Cart Page] Item Removed), pure reducers compute the next state, selectors read it, and effects handle side effects such as HTTP calls by listening for actions and dispatching new ones.

// a component never changes state directly; it reports an event
this.store.dispatch(CartPageActions.itemRemoved({ productId }));

// and reads state through memoised selectors, as signals
readonly count = this.store.selectSignal(selectCartCount);

The indirection is the point. Every change is a serialisable action, so the Redux DevTools browser extension can show a complete, replayable log of what happened and let you step back through state (time-travel debugging). Several features can react to the same action without knowing about each other. The cost is more files and more ceremony for every change. Lesson 14.3 walks through the modern, standalone version of NgRx Store.

NgRx is not the only option: NGXS, Elf, TanStack Query and others exist and have their own users, but this tutorial focuses on the NgRx packages because they are the most widely used in Angular projects.

Decision table #

Question Points to
Is the state used by one component or a small subtree? Level 1
Is it server data you fetch and display? resource() / httpResource() first, maybe inside a level 2 or 3 store
Shared across a few routes, small team, a handful of stores? Level 2
Many shared stores, or entity collections, or several teams who need one consistent pattern? Level 3
Need a full action log, Redux DevTools time-travel, or several features reacting to the same events? Level 4
Audit, undo/redo or "replay what the user did" requirements? Level 4 (actions are an event log by design)
Existing app already on NgRx Store and working? Keep it; add Signal Stores for new features (lesson 14.3)

By app and team size, a rough guide:

App and team Typical choice
Small app, 1–3 developers Signals and services only
Medium app, one team, 10–40 screens Services for simple cases, Signal Store for feature state
Large app, several teams Signal Store as the default, NgRx Store where an event log or DevTools history matters
Very large, long-lived enterprise app with strict auditing NgRx Store for core domain state, Signal Store or services at the edges

One note on DevTools: Redux DevTools support is built into NgRx Store through @ngrx/store-devtools. Signal Store doesn't ship it; community packages add a DevTools integration, but you won't get the same action-by-action history, because Signal Store methods aren't actions. If you want Signal Store with an action-like flow, NgRx also provides an Events plugin (@ngrx/signals/events), which lesson 14.2 mentions briefly.

Gotchas #

Symptom Cause Fix
A store has hundreds of lines of trivial getters and setters UI-only state (open/closed, current tab) moved into a global store Keep UI state in components (level 1)
Components modify shared state in unexpected ways The service exposes writable signals Keep writable signals private; expose asReadonly() and methods
Template doesn't update after a store method runs State mutated in place (push, property assignment) Return new arrays and objects from update() or patchState
Two copies of the "same" state disagree A service provided both at root and in a component's providers Provide shared stores in one place only
Every change touches four files NgRx Store adopted for a small app Drop to level 2 or 3 for new features
Hard to tell why state changed Many places call store methods with no trace Add logging via a store feature (level 3), or use NgRx Store with DevTools (level 4)

What's next #

Lesson 14.2 goes deep on NgRx Signal Store: withState, withComputed, withMethods, withHooks, withProps, rxMethod, entity management, custom features and testing. Lesson 14.3 then covers classic NgRx Store with actions, reducers, selectors and functional effects, and how to migrate between the two. The rest of Module 14 moves on to custom form controls, Angular Material and the CDK, internationalization and app architecture.

Try it yourself #

Pick one shared service in your app that holds state. Check it against the level 2 pattern: are its writable signals private, are updates immutable, is derived state in computed()? Then ask whether any of the problems that justify a library (repeated loading logic, entity handling, a need for change history) actually show up in it.

YouWe’re starting a new admin app: about 25 screens, 4 developers, mostly CRUD over a REST API. Should we set up NgRx Store from day one?
Claude · used get_best_practicesProbably not from day one. For mostly CRUD screens, start with httpResource() for reads and keep screen state in components. Where state is shared, such as the signed-in user, permissions or a selected tenant, use a small service with private signals and read-only views. If, after the first few features, you notice every list screen re-implementing loading flags and id-keyed updates, introduce @ngrx/signals and package that pattern once with withEntities and a custom feature. Choose NgRx Store only if you have a concrete need for an action log or Redux DevTools time-travel, for example an audit requirement. Whatever you choose, agree on one pattern per level and document it, so four developers don’t build four different kinds of store.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *