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.
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 usingupdate()/set()with new references — this breaks change detection andcomputed()memoization the same way it breaksOnPushcomponents. - 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.
Related Reading
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.
Related Articles
RxJS vs Signals in Modern Angular Applications
How RxJS and Signals coexist in modern Angular apps, and practical patterns for using both together.
Angular Standalone Components: Why They Matter
Why standalone components became Angular's default architecture, and how to structure an app around them.
Angular Performance Optimization: Practical Techniques
Practical, production-tested techniques for speeding up Angular applications — from change detection to bundle size.