SignalStore is the first NgRx I'd actually reach for
Classic NgRx asked for actions, reducers, effects and selectors before you'd stored a single value. SignalStore is state as signals, methods that patch it, and rxMethod for the async — composable with custom features, scoped where you want it.
I've spent a good chunk of my career talking teams out of NgRx. Not because the Redux pattern is wrong — because most apps that adopted it didn't have a Redux-shaped problem. They had a "two components need the same list" problem, and they paid for it with an action, a reducer case, an effect, a selector, and three files, per feature. The ratio of ceremony to value was brutal.
SignalStore is the first time the NgRx name has shown up in my projects without me wincing. It throws out the dispatch/reducer machinery and builds on signals directly. State is signals you read; methods are functions that change them. That's the model.
export const CartStore = signalStore(
{ providedIn: "root" },
withState({ items: [] as Item[], loading: false }),
withComputed(({ items }) => ({
count: computed(() => items().length),
total: computed(() => items().reduce((s, i) => s + i.price, 0)),
})),
withMethods((store) => ({
add(item: Item) {
patchState(store, { items: [...store.items(), item] });
},
clear() {
patchState(store, { items: [] });
},
})),
);count and total are signals. add and clear are methods. A component injects the store and reads store.count() in its template like any other signal. No store.select(...), no string action types, no switch statement. The thing that used to be five concepts is now two: withState and withMethods.
patchState, and the immutability you can't skip
You never assign to state directly — you call patchState with a partial update or an updater function. And the array spread in add above isn't optional styling: SignalStore state follows the same rule signals always do. Mutating the existing array in place (items().push(item)) changes the contents without changing the reference, so the signal never fires and your computed count goes stale. Treat state as immutable, produce new references, and the reactive graph stays honest. This is the one rule that trips people coming from the mutate-anything world.
rxMethod is where the async lives
Real stores fetch. rxMethod is the bridge to RxJS for exactly that — it takes a stream of inputs and lets you run an Observable pipeline per emission, with proper cancellation:
loadItems: rxMethod<string>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap((cartId) =>
cartService.fetch(cartId).pipe(
tapResponse({
next: (items) => patchState(store, { items, loading: false }),
error: () => patchState(store, { loading: false }),
}),
),
),
),
)The switchMap there is doing the work an NgRx effect used to: a second call cancels the first in-flight request, so a fast-typing user doesn't race two responses into your state out of order. This is the spot where the "signals killed RxJS" crowd gets corrected — streams are still the right tool for events over time, and SignalStore is happy to let them feed the signals. It's the same boundary as everywhere else in modern Angular: signals hold state, streams describe the async.
The decision that actually matters: where you provide it
providedIn: "root" gives you one global instance — right for genuinely app-wide state like the cart or the current user. But you can also leave that out and list the store in a component's providers array. Now you get a fresh instance scoped to that component and its children, created when the feature mounts and destroyed when it unmounts. State that belongs to one screen lives and dies with that screen, no manual reset.
This is the call I see teams get wrong most often. They make every store global, then write cleanup code to wipe stale feature state on every navigation — reinventing component scope by hand. If the state is "this page's state," scope it to the page. If it's "the app's state," put it in root. withEntities is worth knowing for the collection cases — it gives you normalized add/update/remove over an entity map so you're not hand-rolling the same dictionary logic again.
Do you even need it?
Honest answer: often not. A plain @Injectable service holding a few signal()s and exposing computed() getters covers a surprising amount of shared state, and it's the lighter choice for a single feature with simple needs. SignalStore earns its dependency when you want conventions a team will follow without a meeting — a predictable shape for every store, async that's already cancellation-safe, and withEntities/custom withX features you can compose and reuse across stores.
The way I'd put it: plain signals service is the answer until you find yourself reinventing the same store skeleton for the fourth time. At that point SignalStore is the skeleton, already written, and unlike the NgRx of five years ago it doesn't make you pay for a Redux you don't have.