Before signals, most Angular apps ended up with some flavor of NgRx or a service full of BehaviorSubjects just to get change detection to behave predictably. Signals remove a lot of that necessity — fine-grained reactivity is now built into the framework — but that also means teams are re-litigating a question that used to have one obvious answer: do you still need a store, and if so, what does it actually buy you now?

The floor: a service with writable signals

For most feature-level state, a plain injectable service holding writable signals is enough, and it's dramatically simpler than the machinery it replaces:

typescript
@Injectable({ providedIn: 'root' })
export class CartStore {
  private items = signal<CartItem[]>([]);

  readonly cartItems = this.items.asReadonly();
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  readonly itemCount = computed(() => this.items().length);

  add(item: CartItem) {
    this.items.update(current => [...current, item]);
  }

  remove(id: string) {
    this.items.update(current => current.filter(i => i.id !== id));
  }
}

Exposing asReadonly() instead of the raw signal is the load-bearing detail here — it lets any component inject CartStore and read cartItems() reactively, while write access stays funneled through add/remove, which is where you'd add validation or logging. This gets you 90% of what a store framework gives you: single source of truth, computed derived state, and no manual subscription cleanup, since signals unsubscribe themselves when the reading context is destroyed.

When a plain service starts to strain

The pattern breaks down once state needs things a service alone doesn't provide cleanly: time-travel debugging across a whole app, a serializable action log for support tooling, or coordinated updates across many independent slices that need to happen atomically. That's the point where @ngrx/signals earns its keep — it keeps the signal-based API surface but adds structure for larger state trees:

typescript
export const CartStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as CartItem[] }),
  withComputed(({ items }) => ({
    total: computed(() => items().reduce((sum, i) => sum + i.price * i.quantity, 0)),
  })),
  withMethods((store) => ({
    add(item: CartItem) {
      patchState(store, { items: [...store.items(), item] });
    },
  })),
);

Functionally this is close to the hand-rolled version, but signalStore standardizes the shape across a codebase — every store looks the same to a new team member, which matters more as the number of stores grows than as any single store grows.

Don't put everything behind a store

The most common signal-era mistake is treating every piece of state as global by default. Local UI state — whether a dropdown is open, which tab is active, form-in-progress values — belongs in signal() calls inside the component itself, not hoisted into a shared store. Promoting state to shared scope should be a deliberate decision made when a second, unrelated component genuinely needs to read it, not a default. Signals made state management cheap enough that the discipline now has to come from where you draw the boundary, not from the tooling enforcing it for you.