Angular

NgRx vs Signals: Choosing State Management

When a full NgRx store still earns its complexity in an Angular app, and when Signals-based state is simpler and sufficient.

Tayyab Roy7 min read
Share:

Every Angular team eventually has this conversation: do we need NgRx, or is component/service-level state enough? For years, the honest answer for anything beyond a small app was “probably NgRx, because there wasn’t a good built-in alternative.” Signals changed that calculus. Now the real question isn’t “NgRx or nothing” — it’s “does this specific piece of state need NgRx’s guarantees, or is a signal-based store simpler and sufficient.”

This isn’t a case of one approach replacing the other outright. I still reach for NgRx on larger apps, and I still use signals for most feature-level state, often in the same codebase. The rest of this article is about where the line actually falls, with the same small feature — a todo list — implemented both ways so you can see the real difference in code, not just in theory.

The Case for NgRx

NgRx gives you a single, predictable way to change state: components dispatch actions, reducers compute the next state immutably, effects handle side effects (HTTP calls, routing, logging), and selectors derive read-only views for components to consume. The payoff for that structure shows up specifically in:

  • Cross-cutting state that many unrelated features read and write — the current user, feature flags, a shopping cart, notification counts in a header that has no component relationship to the page updating them.
  • Complex side-effect orchestration — a dispatched action triggering an HTTP call, which triggers a websocket subscription update, which triggers a toast notification, with retry/cancellation logic in between. NgRx Effects is built for exactly this.
  • Time-travel debugging and action logs. Redux DevTools showing a full, replayable history of every state change is genuinely useful when reproducing a bug that took a specific sequence of user actions to trigger.
  • Team-scale consistency. On a large team, having one enforced pattern for “how state changes happen” reduces the number of different ad-hoc patterns that show up across features written by different people.

The Case for Signals

Signal-based state is dramatically less ceremony for the common case: state that belongs to one feature or one component tree, doesn’t need a replayable history, and doesn’t fan out to dozens of unrelated consumers.

@Injectable({ providedIn: 'root' })
export class TodoStore {
  private _todos = signal<Todo[]>([]);

  todos = this._todos.asReadonly();
  completedCount = computed(() => this._todos().filter((t) => t.done).length);

  add(text: string) {
    this._todos.update((todos) => [...todos, { id: crypto.randomUUID(), text, done: false }]);
  }

  toggle(id: string) {
    this._todos.update((todos) =>
      todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    );
  }

  remove(id: string) {
    this._todos.update((todos) => todos.filter((t) => t.id !== id));
  }
}

No actions, no reducers, no effects boilerplate for a straightforward synchronous mutation. Components inject the service, read todos() and completedCount() directly in templates, and call methods to mutate state. This is less code, easier for a new team member to read top-to-bottom in one file, and perfectly sufficient for most feature-level state in most apps.

The Same Feature, Implemented in NgRx

Here’s the todo list again, this time the NgRx way, to make the complexity difference concrete rather than abstract.

// todo.actions.ts
export const addTodo = createAction('[Todo] Add', props<{ text: string }>());
export const toggleTodo = createAction('[Todo] Toggle', props<{ id: string }>());
export const removeTodo = createAction('[Todo] Remove', props<{ id: string }>());
// todo.reducer.ts
export interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = { todos: [] };

export const todoReducer = createReducer(
  initialState,
  on(addTodo, (state, { text }) => ({
    todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
  })),
  on(toggleTodo, (state, { id }) => ({
    todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
  })),
  on(removeTodo, (state, { id }) => ({
    todos: state.todos.filter((t) => t.id !== id),
  })),
);
// todo.selectors.ts
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectTodos = createSelector(selectTodoState, (state) => state.todos);
export const selectCompletedCount = createSelector(
  selectTodos,
  (todos) => todos.filter((t) => t.done).length,
);
// todo-list.component.ts
@Component({
  template: `
    @for (todo of todos(); track todo.id) {
      <div (click)="toggle(todo.id)">{{ todo.text }}</div>
    }
    <p>Completed: {{ completedCount() }}</p>
  `,
})
export class TodoListComponent {
  private store = inject(Store);

  todos = this.store.selectSignal(selectTodos);
  completedCount = this.store.selectSignal(selectCompletedCount);

  toggle(id: string) {
    this.store.dispatch(toggleTodo({ id }));
  }
}

Four files and roughly three times the code to do exactly what the signal-based TodoStore did in one file. For a todo list, that’s not a good trade. NgRx’s value isn’t in this example — it’s in what you get when fifteen different features across the app all need to read and react to this same state, when a todo’s completion needs to trigger a side effect (sync to a backend, log analytics, update a separate notifications feature), and when you need DevTools to answer “what sequence of actions got the state into this broken shape” during a production bug investigation.

NgRx SignalStore: A Middle Ground

NgRx SignalStore is worth knowing about specifically because it’s built to sit between these two extremes — it gives you NgRx’s opinionated structure (a store with defined state, computed values, and methods) without the actions/reducers/effects ceremony, and it’s built on signals natively rather than wrapping RxJS state.

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState<{ todos: Todo[] }>({ todos: [] }),
  withComputed(({ todos }) => ({
    completedCount: computed(() => todos().filter((t) => t.done).length),
  })),
  withMethods((store) => ({
    add(text: string) {
      patchState(store, { todos: [...store.todos(), { id: crypto.randomUUID(), text, done: false }] });
    },
    toggle(id: string) {
      patchState(store, {
        todos: store.todos().map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
      });
    },
  })),
);

This gets you a structured, injectable store with a consistent shape across features (useful on a team), while staying close in complexity to the plain-signal version. If the main thing that attracted you to NgRx was consistency of pattern rather than Redux DevTools or Effects-based side-effect orchestration, SignalStore is worth evaluating before committing to full NgRx.

Concrete Guidance: Where the Line Actually Falls

There’s no universal threshold, but here’s the reasoning I actually use when a team asks:

  • A handful of developers, one or two features need shared state, side effects are mostly simple HTTP calls: plain signals in an injectable service. You’ll move faster and the code stays readable without an NgRx mental model tax.
  • A larger team where many people touch the same state concurrently, and you need an enforced, consistent pattern so fifty different “state management” styles don’t emerge across fifty features: NgRx (or SignalStore if the main driver is consistency, not DevTools/Effects).
  • Complex async orchestration — chained side effects, retries, websocket-driven state, cross-feature side effects triggered by a single action: NgRx Effects earns its keep here specifically; this is the scenario signals alone handle worst, because there’s no equivalent primitive for declarative, cancellable, composable side-effect chains.
  • You need production bug reproduction via action replay, or compliance/audit requirements around exactly what changed state and when: NgRx’s action log is a real, concrete advantage that signals don’t have an equivalent for.
  • Mixed apps are normal. It’s entirely reasonable to use NgRx for genuinely cross-cutting, complex state (auth, cart, notifications) while every feature module manages its own local state with plain signals or SignalStore. Don’t treat it as an all-or-nothing architectural decision for the whole app.

None of this is about team or app size in isolation — a five-person team building a trading dashboard with heavy real-time, cross-cutting state may need NgRx more than a twenty-person team building a mostly-CRUD admin panel with isolated features. Judge it by the shape of the state and the side effects, not a headcount number.

If you haven’t yet nailed down the lower-level signal/RxJS interop patterns this decision often depends on (how a signal-based store handles an async HTTP call, for instance), RxJS vs Signals in Modern Angular Applications and Signals vs RxJS: Which Should You Use and When cover that ground directly. And if the state management question is really part of a bigger “how should this app be structured” conversation, Scalable Frontend Architecture is the better starting point.

Common Mistakes

  • Adopting NgRx on day one “because it’ll scale,” before any feature actually needs cross-cutting state or complex effects — it mostly just slows down early development.
  • Avoiding NgRx entirely on a large team and ending up with a dozen inconsistent, hand-rolled signal-service patterns that are individually simple but collectively harder to onboard into than NgRx would have been.
  • Mutating state directly in a signal-based store (this._todos().push(item)) instead of using update()/set() with new references — this breaks change detection and computed() memoization the same way it breaks OnPush components.
  • Using NgRx Effects for state that doesn’t actually have side effects, adding dispatch/reducer/effect ceremony around what’s really just a synchronous local toggle.
#Angular#NgRx#Signals#State Management

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.