
NgRx vs Signals: State Management in Angular 2026

D. Rout
August 8, 2026 8 min read
On this page
If you've shipped an Angular app in the last two years, you've had this argument with yourself: keep the NgRx store you already know, or move the new feature to Signals and skip the boilerplate? In 2026, with @ngrx/signals stable and signalStore covering most of what a reducer used to do, the honest answer is "it depends" — but that's not useful without seeing the same feature built both ways.
Want to go deeper on the fundamentals used throughout this post? Check out my complete guide, Angular Signals: The Complete Developer's Guide, which covers
signal(),computed(), and effects in detail.
So that's what we're doing. We're building a shopping cart — add item, remove item, update quantity, compute totals, and a simulated async checkout call — twice: once with classic NgRx (actions, reducer, selectors, effects) and once with @ngrx/signals. Both versions live side by side in the companion repo, ngrx-vs-signals-2026, so you can diff them yourself instead of taking my word for which one is simpler.
Prerequisites
- Node.js 20+ and Angular CLI 19 (`npm install -g @angular/cli`)
- Working familiarity with Angular standalone components and basic NgRx (actions/reducers)
- Angular Signals fundamentals (
signal(),computed()) — if you need a refresher, see the further reading section - `@ngrx/store`, `@ngrx/effects`, and `@ngrx/signals` installed (all covered in step 1)
Step 1: Scaffold the project and install both state libraries
Clone the companion repo or start from a fresh Angular 19 app and install both flavors of NgRx side by side — this is a comparison, so we need both in one project:
git clone https://github.com/deepakrout/ngrx-vs-signals-2026.git
cd ngrx-vs-signals-2026
npm install
The relevant dependencies in package.json:
"@ngrx/store": "^19.0.0",
"@ngrx/effects": "^19.0.0",
"@ngrx/store-devtools": "^19.0.0",
"@ngrx/signals": "^19.0.0"
Note that @ngrx/signals is a separate package from @ngrx/store. You don't need the classic store installed to use signalStore — we only have both here so the two implementations can share one app.
Step 2: Define the shared shape
Both implementations manage the same data, so the model is shared and lives outside either feature folder, at src/app/shared/models/cart-item.model.ts:
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export interface Product {
id: string;
name: string;
price: number;
}
Keeping this outside both feature folders matters for the comparison — it means the diff between the two implementations is purely about state management, not data modeling.
Step 3: Build the NgRx version
Classic NgRx separates state into four files. Start with actions, in features/cart-ngrx/state/cart.actions.ts:
export const CartActions = createActionGroup({
source: 'Cart',
events: {
'Add Item': props<{ product: Product }>(),
'Remove Item': props<{ id: string }>(),
'Update Quantity': props<{ id: string; quantity: number }>(),
'Clear Cart': emptyProps(),
'Checkout Requested': emptyProps(),
'Checkout Success': emptyProps(),
'Checkout Failure': props<{ error: string }>(),
},
});
Then the reducer, which is the part that grows fastest as a feature gains edge cases:
export const cartReducer = createReducer(
initialState,
on(CartActions.addItem, (state, { product }) => {
const existing = state.items.find((i) => i.id === product.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i,
),
};
}
return { ...state, items: [...state.items, { ...product, quantity: 1 }] };
}),
on(CartActions.removeItem, (state, { id }) => ({
...state,
items: state.items.filter((i) => i.id !== id),
})),
// ...updateQuantity, clearCart, checkout handlers
);
Selectors compose derived state and are what components actually subscribe to:
export const selectCartItems = createSelector(selectCartState, (s) => s.items);
export const selectCartTotal = createSelector(selectCartItems, (items) =>
items.reduce((sum, i) => sum + i.quantity * i.price, 0),
);
And the component consumes all of it through the Store and the async pipe:
export class CartNgrxComponent {
private store = inject(Store);
items$ = this.store.select(selectCartItems);
total$ = this.store.select(selectCartTotal);
addItem(product: Product) {
this.store.dispatch(CartActions.addItem({ product }));
}
}
Full source: features/cart-ngrx/ in the repo. Four files, roughly 120 lines total, before we even get to effects.
Step 4: Build the Signals version with signalStore
@ngrx/signals collapses actions, reducer, and selectors into a single declarative store built from withState, withComputed, and withMethods. Here's the equivalent cart store, from features/cart-signals/cart.store.ts:
export const CartSignalStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ items }) => ({
count: computed(() => items().reduce((sum, i) => sum + i.quantity, 0)),
total: computed(() => items().reduce((sum, i) => sum + i.quantity * i.price, 0)),
})),
withMethods((store) => ({
addItem(product: Product) {
const existing = store.items().find((i) => i.id === product.id);
if (existing) {
patchState(store, {
items: store.items().map((i) =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i,
),
});
return;
}
patchState(store, { items: [...store.items(), { ...product, quantity: 1 }] });
},
removeItem(id: string) {
patchState(store, { items: store.items().filter((i) => i.id !== id) });
},
})),
);
No action creators, no separate selector file, no Store injection token — the store is the injectable. The component reads it directly:
export class CartSignalsComponent {
protected store = inject(CartSignalStore);
addItem(product: Product) {
this.store.addItem(product);
}
}
And the template calls signals as functions instead of piping through async:
<p>Total: ${{ store.total() }}</p>
Full source: features/cart-signals/ — two files, about 70 lines, for equivalent behavior.
Step 5: Handle the async checkout case in both
This is where the comparison gets interesting, because it's the one place Signals doesn't get to skip RxJS entirely.
NgRx handles it the way it always has — an effect listens for the action, does the async work, and dispatches a result action:
@Injectable()
export class CartEffects {
private actions$ = inject(Actions);
checkout$ = createEffect(() =>
this.actions$.pipe(
ofType(CartActions.checkoutRequested),
switchMap(() =>
of(null).pipe(
delay(800),
map(() => CartActions.checkoutSuccess()),
catchError(() => of(CartActions.checkoutFailure({ error: 'Payment gateway timeout' }))),
),
),
),
);
}
Signals reaches for rxMethod from @ngrx/signals/rxjs-interop — same RxJS operators, no separate effects class, no action dispatch round-trip:
checkout: rxMethod<void>(
pipe(
tap(() => patchState(store, { checkoutStatus: 'pending', error: null })),
switchMap(() =>
of(null).pipe(
delay(800),
tap(() => patchState(store, { checkoutStatus: 'success', items: [] })),
catchError(() => {
patchState(store, { checkoutStatus: 'error', error: 'Payment gateway timeout' });
return of(null);
}),
),
),
),
),
The takeaway: neither approach eliminates RxJS for genuinely async, cancellable work. Signals just stops making you use it for synchronous state updates, which is where most of NgRx's historical boilerplate actually came from.
Step 6: Wire up routes to compare both live
The demo app routes /ngrx and /signals to their respective components, and registers the NgRx store only where it's needed, in app.config.ts:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({ cart: cartReducer }),
provideEffects([CartEffects]),
provideStoreDevtools({ maxAge: 25, logOnly: false }),
// CartSignalStore needs no entry here — it's providedIn: 'root'
],
};
Run npm start and flip between the two routes. Behaviorally they're identical. The difference is entirely in what you had to write to get there.
Reference: NgRx vs Signals at a glance
| Dimension | NgRx (Store + Effects) | Signals (@ngrx/signals) |
|---|---|---|
| Boilerplate for a CRUD-ish feature | High — actions, reducer, selectors, module wiring | Low — one signalStore call |
| Learning curve | Steep — actions, reducers, selectors, effects, RxJS operators | Moderate — signals, computed, withMethods |
| DevTools | Mature, full action log and time-travel | Supported via @ngrx/store-devtools integration, less granular |
| Async / side effects | First-class via Effects, strong cancellation patterns | Via rxMethod, same power, less ceremony |
| Bundle size impact | Larger — store, effects, devtools packages | Smaller — signals are built into Angular core |
| Testability | Reducers are pure functions, very easy to unit test | Store methods are easy to test; less indirection to mock |
| Change detection | Works with Zone.js or zoneless via async pipe |
Native fit for zoneless Angular, no pipe needed |
| Best-fit use case | Large teams, complex cross-cutting state, heavy async orchestration | Feature-local state, small-to-mid teams, new zoneless apps |
Neither row-for-row loses badly. The honest read: NgRx still earns its keep in large, cross-team codebases where the action log and time-travel debugging pay for the ceremony. Signals wins for feature-scoped state in newer, especially zoneless, apps where that ceremony has no payoff.
What's next
- Migrate incrementally: swap one feature's NgRx store for a
signalStorewithout touching the rest of the app — both can coexist, as this repo demonstrates. - **Entity patterns**: try `withEntities` from `@ngrx/signals/entities` for the cart items instead of manual array operations, and compare it to `@ngrx/entity`'s adapter API.
- Testing strategy: write unit tests for both
cartReducerandCartSignalStoreand compare how much setup each requires — a good follow-up post on its own. - Zoneless Angular: rebuild this demo with
provideZonelessChangeDetection()and see how much of the NgRxasyncpipe wiring becomes unnecessary.
Further reading
- Angular Signals guide — official docs on
signal(),computed(), and effects - @ngrx/signals documentation — `signalStore`, `withState`, `withMethods`, `rxMethod`
- NgRx Store guide — actions, reducers, and selectors reference
- NgRx Effects guide — side-effect handling patterns
- RxJS documentation — operators used in both the effect and the
rxMethod - Zoneless Angular guide — relevant context for the change-detection row above
Everything in this post is runnable, not just readable — clone ngrx-vs-signals-2026, run npm install && npm start, and switch between /ngrx and /signals to see both stores update the same UI in real time. If you find a cleaner way to express either version, a PR is welcome.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!