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.