Every Angular team eventually debates state management. One developer wants NgRx for “proper architecture.” Another says signals make everything simpler. A third argues that injectable services with BehaviorSubjects work fine. They are all right — for different scenarios.
The Three Approaches
1. Simple Services (BehaviorSubject / Signals)
// Simplest approach: service with signals
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.qty, 0)
);
readonly count = computed(() =>
this._items().reduce((sum, item) => sum + item.qty, 0)
);
addItem(product: Product) {
this._items.update(items => {
const existing = items.find(i => i.productId === product.id);
if (existing) {
return items.map(i =>
i.productId === product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...items, { productId: product.id, name: product.name, price: product.price, qty: 1 }];
});
}
removeItem(productId: string) {
this._items.update(items => items.filter(i => i.productId !== productId));
}
clear() {
this._items.set([]);
}
}
// Component usage: dead simple
@Component({
template: \`
<span>Cart ({{ cart.count() }})</span>
<span>Total: {{ cart.total() | currency }}</span>
\`
})
export class CartBadgeComponent {
cart = inject(CartService);
}
2. NgRx Store (Redux Pattern)
// Actions: what happened
export const CartActions = createActionGroup({
source: 'Cart',
events: {
'Add Item': props<{ product: Product }>(),
'Remove Item': props<{ productId: string }>(),
'Clear Cart': emptyProps(),
'Load Cart Success': props<{ items: CartItem[] }>(),
'Load Cart Failure': props<{ error: string }>(),
},
});
// Reducer: how state changes
export const cartReducer = createReducer(
initialState,
on(CartActions.addItem, (state, { product }) => ({
...state,
items: addOrIncrement(state.items, product),
})),
on(CartActions.removeItem, (state, { productId }) => ({
...state,
items: state.items.filter(i => i.productId !== productId),
})),
on(CartActions.clearCart, () => initialState),
on(CartActions.loadCartSuccess, (state, { items }) => ({
...state,
items,
loaded: true,
})),
);
// Selectors: derived state
export const selectCartItems = createSelector(selectCart, state => state.items);
export const selectCartTotal = createSelector(selectCartItems, items =>
items.reduce((sum, i) => sum + i.price * i.qty, 0)
);
// Effects: side effects (API calls)
@Injectable()
export class CartEffects {
loadCart$ = createEffect(() =>
this.actions$.pipe(
ofType(CartActions.loadCart),
switchMap(() =>
this.cartApi.load().pipe(
map(items => CartActions.loadCartSuccess({ items })),
catchError(error => of(CartActions.loadCartFailure({ error: error.message })))
)
)
)
);
constructor(private actions$: Actions, private cartApi: CartApiService) {}
}
// Component usage
@Component({
template: \`
<span>Total: {{ total$ | async | currency }}</span>
\`
})
export class CartBadgeComponent {
total$ = this.store.select(selectCartTotal);
constructor(private store: Store) {}
}
3. Signal Store (NgRx Signals)
// NgRx SignalStore: NgRx concepts with signal ergonomics
export const CartStore = signalStore(
{ providedIn: 'root' },
withState<CartState>({
items: [],
loaded: false,
error: null,
}),
withComputed(({ items }) => ({
total: computed(() => items().reduce((sum, i) => sum + i.price * i.qty, 0)),
count: computed(() => items().reduce((sum, i) => sum + i.qty, 0)),
})),
withMethods((store, cartApi = inject(CartApiService)) => ({
addItem(product: Product) {
patchState(store, { items: addOrIncrement(store.items(), product) });
},
removeItem(productId: string) {
patchState(store, { items: store.items().filter(i => i.productId !== productId) });
},
async loadCart() {
try {
const items = await firstValueFrom(cartApi.load());
patchState(store, { items, loaded: true });
} catch (e) {
patchState(store, { error: 'Failed to load cart' });
}
},
})),
);
// Component usage
@Component({
template: \`
<span>Cart ({{ store.count() }})</span>
<span>Total: {{ store.total() | currency }}</span>
\`
})
export class CartBadgeComponent {
store = inject(CartStore);
}
Comparison Table
| Criteria | Services + Signals | NgRx Store | NgRx SignalStore |
|---|---|---|---|
| Boilerplate | Minimal | High (actions, reducers, effects, selectors) | Medium |
| Learning Curve | Low | High (Redux concepts) | Medium |
| DevTools | None | Excellent (time-travel debugging) | Limited |
| Predictability | Good | Excellent (strict unidirectional flow) | Good |
| Testing | Easy (just call methods) | Structured (test reducers, effects separately) | Easy |
| Async Handling | Manual (RxJS in service) | Effects (declarative) | Methods (imperative async) |
| Team Scale | Small (1-5 devs) | Large (10+ devs, enforced patterns) | Medium (5-10 devs) |
Decision Framework
- Small app, small team (1-5 devs): Services with signals. Simple, fast, no overhead.
- Medium app, medium team (5-10 devs): NgRx SignalStore. Structured patterns without Redux verbosity.
- Large enterprise app (10+ devs): NgRx Store. Enforced architecture, time-travel debugging, established patterns.
- State shared across many components: Any centralized store (NgRx or signal service at root).
- State local to one component: Just use a signal in the component. No store needed.
- Complex async workflows: NgRx Effects or RxJS in services. Signals alone do not handle streams.
Key Takeaways
- Start with the simplest approach that works — signal services for most apps
- NgRx Store earns its complexity at scale — with large teams, the enforced patterns prevent chaos
- NgRx SignalStore is the middle ground — structured state management without Redux boilerplate
- Local state stays local — not everything belongs in a global store
- You can mix approaches — global auth state in a service, feature state in NgRx, component state in signals
- The best state management is the one your team understands — a well-used simple approach beats a misused complex one
State management is a spectrum, not a binary choice. Match the tool to the problem: signals for simple state, signal stores for moderate complexity, full NgRx for enterprise-scale applications with strict architectural requirements. The goal is managing complexity, not adding it.