State management in Angular ranges from local component signals to application-wide stores. The right choice depends on how widely state needs to be shared and how complex the update logic is. Angular Signals (17+) have changed the calculus — many apps that previously needed NgRx can now use signals in services.

Key Points

  • Component state: signal() — use for UI state that does not leave the component (toggle, count)
  • Service state: signal() or BehaviorSubject in an @Injectable service — share state between sibling/unrelated components
  • NgRx Store: Redux-pattern — Actions → Reducers → State → Selectors; use when state is complex, global, and needs time-travel debugging
  • NgRx Signals Store (@ngrx/signals): lighter NgRx with Signals — withEntities, withMethods, withComputed — Angular 17+
  • Zustand / Elf: lightweight reactive stores — simpler than NgRx for medium-complexity state
  • Derived state: computed() — automatically re-derives from one or more signals when dependencies change
  • Side effects in NgRx: Effects — listen to Actions, call APIs, dispatch new Actions
  • Selector memoisation: createSelector() in NgRx — re-runs only when input slice changes
  • State immutability: always create new objects/arrays instead of mutating — required for OnPush and NgRx to work correctly
ApproachScopeComplexityBest for
signal() in componentComponent onlyMinimalUI toggles, local counters
signal() in serviceFeature/app-wideLowCart, auth, user preferences
NgRx Signals StoreFeature/app-wideMediumEntity collections, CRUD pages
NgRx StoreApp-wideHighComplex business flows, time-travel debug

Angular state: signals service for simple state, NgRx Signals Store with entity collection and computed

// 1. Simple service with signals — no NgRx needed for most apps
@Injectable({ providedIn: 'root' })
export class TodoService {
  private todos = signal<Todo[]>([]);

  readonly all     = this.todos.asReadonly();
  readonly pending = computed(() => this.todos().filter(t => !t.done));
  readonly count   = computed(() => this.todos().length);

  add(text: string)       { this.todos.update(list => [...list, { id: Date.now(), text, done: false }]); }
  toggle(id: number)      { this.todos.update(list => list.map(t => t.id === id ? { ...t, done: !t.done } : t)); }
  remove(id: number)      { this.todos.update(list => list.filter(t => t.id !== id)); }
}

// 2. NgRx Signals Store — entity collection with API integration
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity, removeEntity } from '@ngrx/signals/entities';

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withEntities<Product>(),           // id-keyed entity collection
  withState({ loading: false }),
  withComputed(({ entities }) => ({
    inStock: computed(() => entities().filter(p => p.stock > 0)),
    totalValue: computed(() => entities().reduce((sum, p) => sum + p.price * p.stock, 0))
  })),
  withMethods((store, api = inject(ProductApi)) => ({
    async loadAll() {
      patchState(store, { loading: true });
      const products = await api.getProducts();
      patchState(store, setAllEntities(products), { loading: false });
    },
    removeProduct: (id: string) => patchState(store, removeEntity(id))
  }))
);

// Usage in component
@Component({ standalone: true, ... })
export class ProductListComponent {
  store = inject(ProductStore);
  ngOnInit() { this.store.loadAll(); }
  // template: @for (p of store.inStock(); track p.id)
}

Real-World Example

Most Angular apps don't need NgRx. A service with signals handles 80% of state management needs and is far simpler to learn, debug, and test. Reach for NgRx when you need: (1) complex derived state from multiple sources with memoisation guarantees, (2) optimistic updates with rollback, (3) Redux DevTools time-travel debugging, or (4) a team already familiar with the Redux pattern.