Skip to the solution
HardFrontend12 min read

What are the differences between State Management libraries in Angular?

41 views

The problem

Question presented to candidate: "We are starting a new Angular 22 app with about a dozen feature areas and three teams. Some people want NgRx, some want NGXS, and some say services with signals are enough. Compare the main state-management options in Angular and tell me how you would decide."

What a strong answer should cover:

  • First split the problem: local UI state, shared client state, and server state (cached API data) need different tools.
  • Services with signals (or a BehaviorSubject): no dependency, minimal ceremony; discipline (private writable state, read-only views, methods) is up to the team.
  • NgRx Store + Effects: Redux pattern, actions, pure reducers, selectors, DevTools, the most structure and the most code; strong for large, event-driven, multi-team apps.
  • NgRx SignalStore (@ngrx/signals): signal-based, composable features (withState, withComputed, withMethods, rxMethod, entities, events plugin), protected state by default; a middle ground.
  • NGXS (class and decorator based Redux style) and Elf (the successor recommended by the Akita team); Akita itself is no longer recommended for new work.
  • Server state tools: Angular resource() / httpResource() (stable in v22) or TanStack Query (Angular adapter still experimental) for caching, loading and error status.
  • Decision criteria: team size, how much state is truly shared, debugging/audit needs, async complexity, learning curve, bundle size, and consistency across teams.

Clarifying questions expected:

  • "How much state is genuinely shared across features, versus local to a screen or derived from the server?"
  • "Do we need time-travel debugging, undo, or a strict audit of state changes?"

Code / implementation expected: Optional β€” the same small feature written as a signal service and as a SignalStore (or store) to compare the ceremony.

  • state-management
  • architecture

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read Β· 9 sections

Read the code

AngularThe no-library option: a root service with a private signal and read-only computed views
import { Component, Injectable, computed, inject, signal } from "@angular/core";

interface Todo {
  id: number;
  title: string;
  done: boolean;
}

// The "no library" option: an injectable service with a private writable signal,
// public read-only views, and methods as the only way to change state.
@Injectable({ providedIn: "root" })
export class TodoService {
  private readonly state = signal({
    todos: [
      { id: 1, title: "Write reducer", done: true },
      { id: 2, title: "Write effect", done: false },
    ] as Todo[],
    filter: "all" as "all" | "open",
  });

  readonly filter = computed(() => this.state().filter);
  readonly visible = computed(() => {
    const { todos, filter } = this.state();
    return filter === "open" ? todos.filter((t) => !t.done) : todos;
  });
  readonly openCount = computed(() => this.state().todos.filter((t) => !t.done).length);

  toggle(id: number) {
    this.state.update((s) => ({ ...s, todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
  }
  setFilter(filter: "all" | "open") {
    this.state.update((s) => ({ ...s, filter }));
  }
}

@Component({
  selector: "app-counter-badge",
  template: `<span>Badge sees {{ todos.openCount() }} open</span>`,
})
export class CounterBadge {
  // A second component injects the SAME root instance, so the state is shared.
  todos = inject(TodoService);
}

@Component({
  selector: "app-root",
  imports: [CounterBadge],
  template: `
    <app-counter-badge />
    <button (click)="todos.setFilter(todos.filter() === 'all' ? 'open' : 'all')">Filter: {{ todos.filter() }}</button>
    <ul>
      @for (t of todos.visible(); track t.id) {
        <li (click)="todos.toggle(t.id)">{{ t.done ? "[x]" : "[ ]" }} {{ t.title }}</li>
      }
    </ul>
  `,
})
export class AppComponent {
  todos = inject(TodoService);
}
AngularThe same todo feature as an NgRx SignalStore with withState, withComputed, withMethods and rxMethod
import { Component, computed, inject } from "@angular/core";
import { patchState, signalStore, withComputed, withHooks, withMethods, withState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { Observable, delay, of, pipe, switchMap, tap } from "rxjs";

interface Todo {
  id: number;
  title: string;
  done: boolean;
}

// Stands in for HttpClient.
function fetchTodos(): Observable<Todo[]> {
  return of([
    { id: 1, title: "Write reducer", done: true },
    { id: 2, title: "Write effect", done: false },
  ]).pipe(delay(200));
}

// A SignalStore: state, derived state and methods in one injectable, built from features.
export const TodoStore = signalStore(
  { providedIn: "root" },
  withState({ todos: [] as Todo[], filter: "all" as "all" | "open", loading: false }),
  withComputed(({ todos, filter }) => ({
    visible: computed(() => (filter() === "open" ? todos().filter((t) => !t.done) : todos())),
    openCount: computed(() => todos().filter((t) => !t.done).length),
  })),
  withMethods((store) => ({
    toggle(id: number) {
      // patchState is the only way to write: the state signals are read-only outside the store.
      patchState(store, { todos: store.todos().map((t) => (t.id === id ? { ...t, done: !t.done } : t)) });
    },
    setFilter(filter: "all" | "open") {
      patchState(store, { filter });
    },
    // rxMethod: an RxJS pipeline you can call like a function (here with no argument).
    load: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() => fetchTodos()),
        tap((todos) => patchState(store, { todos, loading: false })),
      ),
    ),
  })),
  withHooks({
    onInit(store) {
      store.load();
    },
  }),
);

@Component({
  selector: "app-root",
  template: `
    <button (click)="store.setFilter(store.filter() === 'all' ? 'open' : 'all')">Filter: {{ store.filter() }}</button>
    <p>{{ store.loading() ? "loading..." : store.openCount() + " open" }}</p>
    <ul>
      @for (t of store.visible(); track t.id) {
        <li (click)="store.toggle(t.id)">{{ t.done ? "[x]" : "[ ]" }} {{ t.title }}</li>
      }
    </ul>
  `,
})
export class AppComponent {
  store = inject(TodoStore);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 121 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
End of the topic

That was the last Angular question.

Pick another topic