The feature was a filter panel. A few controls, a bit of derived state, one place on the screen that cared about the result. The kind of thing you could hold in your head all at once. And I built it on the full classic NgRx stack: an action for every change, a reducer to fold those actions into state, a selector to read it back, an effect for the one async call it made. Three files to describe state that a single component actually owned.
It worked. It also read like a form filled out in triplicate. When I came back to it a month later, the thing I had to reconstruct wasn't the feature — it was the ceremony around it. I'd reached for the heaviest tool in the box out of habit, because the app used it everywhere, and I'd charged one small feature a tax it never needed to pay.
State has a shape and a reach
The idea that fixed this for me is that state has two properties worth naming before you pick a tool: its shape and its reach.
Shape is how complicated the state is — a boolean, a list, a normalized graph of entities with relationships between them. Reach is how far the state travels — one component, a handful of siblings, or the whole application at once. Most of the tooling arguments I've sat through were really arguments about shape, when the decision that actually matters is reach. A global store exists to give distant parts of an app one agreed-upon source of truth. If nothing distant needs the state, most of what a store offers is overhead you're paying to solve a problem you don't have.
So I stopped defaulting, and started matching the tool to the reach.
State that lives and dies inside one component — a toggle, the active tab, a scrap of form UI — is a signal and nothing more. No service, no store, no indirection. When a few components need to share it, a small injectable service that holds signals and exposes them read-only is a real, first-class answer, not a lesser one. You keep the writable signal private, hand out a computed or an asReadonly view, and mutate through methods. For a surprising amount of an app, that is the whole state layer.
The trouble starts when that hand-rolled service grows. You add immutable-update helpers so a stray mutation doesn't silently skip change detection. You add a way to run an async load and track its loading flag. You add lifecycle cleanup. One morning you notice you've reimplemented a store by hand, badly. That's the moment NgRx SignalStore earns its place — not as "NgRx with less typing," but as a structured, signal-native container that gives you exactly those pieces as composable features.
The middle of the ladder, in code
SignalStore is built by composing features in order. withState seeds the state as signals, withComputed derives from them, withMethods holds the operations, and patchState is the one sanctioned way to update — it enforces the immutable write that Angular signals require, since they compare by reference and a mutated-in-place object never notifies. Async lives in rxMethod, imported from @ngrx/signals/rxjs-interop, which drives an RxJS pipeline from a signal or a value and manages its own subscription:
type FilterState = {
query: string;
results: Result[];
loading: boolean;
};
export const FilterStore = signalStore(
{ providedIn: 'root' }, // omit this to scope the store to a component instead
withState<FilterState>({ query: '', results: [], loading: false }),
withComputed(({ results }) => ({
resultCount: computed(() => results().length),
})),
withMethods((store, api: SearchApi = inject(SearchApi)) => ({
setQuery(query: string): void {
patchState(store, { query });
},
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, { loading: true })),
switchMap((query) =>
api.search(query).pipe(
tap((results) => patchState(store, { results, loading: false })),
),
),
),
),
})),
);
The single line that decides the store's reach is { providedIn: 'root' }. Keep it, and you have one global instance. Drop it and list the store in a component's providers instead, and you get a fresh instance scoped to that component's lifecycle — the same API, serving local state. That one knob is why SignalStore covers so much middle ground: it doesn't force you to choose global-ness to get structure.
When the heavy tool is the right tool
None of this makes classic NgRx wrong. It makes it specific. Reach for @ngrx/store with actions, reducers, and effects when you genuinely want the Redux discipline: every change is a serializable action, many reducers and effects can react to the same action, and you get a single event log you can replay in DevTools to see exactly what happened and in what order. On a large app with a large team, that decoupling and that audit trail are worth their weight — the ceremony I resented in a filter panel is the same ceremony that keeps a hundred-route app from turning into a pile of tangled mutations. NgRx's own documentation draws the line in the same place: Store is for app-wide state, and for temporary or local state it points you at Signals.
Here's the ladder I actually use now, top to bottom by reach:
one component, dies with it → signal() / computed()
a few components, simple logic → injectable service holding signals
outgrown that, want structure → NgRx SignalStore (local or providedIn: 'root')
app-wide, many reactors, DevTools → classic NgRx: actions + reducers + effects
Two traps are worth naming because I've fallen into both. The first is treating "I use signals" as if it meant "I have state management" — a signal is a reactive value, not a store, and a screen full of loose signals with no seam between them is its own kind of mess. The second is the one that cost me those three files: assuming that important state must mean a global store. Importance is about shape and stakes. The store is about reach. A small filter panel can matter a great deal and still belong entirely to one component.
Match the Tool to the Reach of the State
The version of this feature I'd write today is a component-scoped SignalStore — structured enough to read cleanly in a month, light enough that the structure never outweighs the feature. I didn't get there by learning a new library. I got there by asking a smaller question before I opened one: how far does this state actually need to travel? Answer that honestly and the tool mostly picks itself. Answer it out of habit and you'll spend your afternoons filling out forms in triplicate for state that never leaves the room.
You can see both ends of the ladder running side by side — a SignalStore managing local account state, and classic NgRx driving a transactions feature with actions and effects — on the signal-store demo and the classic-NgRx demo, each with its source open beside it.


