Let’s be serious for a moment. This probably won’t please everyone, but at some point we need to put the hype aside and focus on facts.
We need to stop treating the NgRx team’s signalStore as the one sacred tool for state management in Angular.
Yes, as we’ll see, signalStore has major strengths. But in practice, and with the way it is often promoted, things can go in the wrong direction.
Why am I criticizing it?
- As the stats show, it keeps getting more popular, so more and more developers are using it and discovering it.
- There are lots of conference talks promoting it, which is great, but we also need to talk about its limits.
- signalStore has left very little room for new signal-based patterns to emerge, especially patterns that leverage the reactive nature of Signals and their deep integration with Angular. It has almost become “the right way” to work with Signals, and that is unfortunate.
- It is marketed as an all-purpose Swiss Army knife, but what if it is not?
When you use a tool, you need to understand both its strengths and its limits. Otherwise, you risk building technical debt hidden behind the fact that the tool is “cool” or “trendy.”
With that in mind, I will also highlight some Signal-based patterns that are still not very popular, but that signalStore largely misses.
This article is for people who already use signalStore or want to use it, but who do not yet see how things can go wrong (hopefully before it is too late).
Before anything else, I want to congratulate the NgRx team for their work. It is a very powerful tool with major advantages, and it opens the door to a new style of state management in Angular.
I have taken a lot of inspiration from it to build my own state-management tools, and I think that is great for the Angular community.
Where to start? Do you like Angular resources?
Let me tell you a story about a young Angular enthusiast, Simon.
At work, signalStore is implemented everywhere, and we ended up talking about resources and how to use them.
Personally, I had stopped using signalStore for a while in favor of other patterns. That created some misunderstandings in our discussion, but not in the way you might think.
The goal was to build a page that displays User details based on a URL parameter (id).
My view of the solution:
- The id is read by the component/page through an input (using withComponentInputBindings).
- We use a resource and pass this id signal as params. Whenever it changes, it triggers an API call for that new id.
Nothing complicated. This is a very standard use of resources.
// component/page
@Component()
class UserDetails {
id = input.required<string>();
userService = inject(UserService);
userDetailsResource = resource({
params: this.id,
loader: (id) => this.userService.getUserDetails(id),
});
}
Simon wanted to avoid handling the logic directly in the component to “separate responsibilities,” and that fit better with the rest of the project.
In short, we would go through signalStore for the API call.
Have you seen signalStore examples for API calls? 😅
Have you noticed how server state is handled in signalStore when following official NgRx examples?
We’ll come back to that. For now, the expected behavior is still simple: the id in my URL changes, so I need to call the API to fetch the new User details.
Here is one possible solution (many variations exist, but the issue is the same):
On the signalStore side, we can imagine an implementation like this. It seems to match what we need.
Id changes => API call to fetch new User details.
const userDetailsStore = signalStore(
withState({ id: undefined as string | undefined }),
withProps(({ id }, userService = inject(UserService)) => ({
userDetails: resource({
params: id,
loader: ({ params: id }) => userService.getUserDetails(id),
}),
})),
);
Then I told him: now pass the id from your component into your signalStore.
And it took me a while to understand why he struggled so much with that step.
How do you pass an input to signalStore?
Honestly, how do you pass an input to signalStore? It is a very simple question, but the answer is too complicated for what we need.
If I am not mistaken, one solution looks like this, where we expose a method to update id.
const UserDetailsStore = signalStore(
withState({ id: undefined as string | undefined }),
withProps(({ id }, userService = inject(UserService)) => ({
userDetails: resource({
params: id,
loader: ({ params: id }) => userService.getUserDetails(id),
}),
})),
withMethods((store) => ({
setId: (id: string) => patchStore(store, { id }), // method to update id in signalStore state
})),
);
On the component side, we need an effect to call this method each time id changes.
@Component({ providers: [UserDetailsStore] }) // create a store instance for this component, destroyed with the component
class UserDetails {
id = input.required<string>();
userDetailsStore = inject(UserDetailsStore);
_syncIdWithStore = effect(() => {
this.userDetailsStore.setId(this.id());
});
}
That is still pretty heavy just to extract logic from our component, don’t you think?
PS: This is an appropriate use of effect for this solution.
We have to provide the store, inject it, create an effect to synchronize component id with store id, and expose a store method to update id.
After that, I told him: wait, I’ll show you an alternative to extract component logic, and it will be insanely simple.
Extracting component logic in the Signals era
function userDetailsResource(id: Signal<string>) {
const userService = inject(UserService);
return resource({
params: id,
loader: ({ params: id }) => userService.getUserDetails(id),
});
}
@Component()
class UserDetails {
id = input.required<string>();
userDetails = userDetailsResource(this.id);
}
And that is it. Yes, this is not a 1:1 port because we cannot inject userDetails into child components. But at no point was it difficult to write a function that extracts logic from our component.
And we barely see this pattern, even though it has become much simpler since inputs became signals.
If you really want to go through dependency injection so you can use it in child components, you can also do that thanks to tools I built (craft-ng).
export const { injectUserDetailsQuery, provideUserDetailsQuery } = craftService(
{ name: "UserDetailsQuery", scope: "toProvid" },
(id: Signal<string>) => {
const userService = inject(UserService);
return resource({
params: id,
loader: (id) => userService.getUserDetails(id),
});
},
);
@Component({ providers: [provideUserDetailsQuery()] })
class UserDetails {
id = input.required<string>();
userDetailsQuery = injectUserDetailsQuery(this.id);
}
There is barely any extra complexity.
3 major limitations of signalStore from this example
1 - Passing inputs to signalStore is complex and adds mental overhead
As we saw, you need a lot of boilerplate to connect an input to signalStore.
That adds cognitive overhead to understand data flow and what is happening in the app.
You can build a helper utility to make input-to-signalStore binding easier. I have shared different versions in my posts.
2 - signalStore is not suited for server states
I was generous: I used resources to manage server state (user details) inside signalStore.
But what value did signalStore add? None, except making API triggers harder to manage.
If we take the official example, which directly handles server state with signalStore:
export const BookSearchStore = signalStore(
withState(initialState),
withMethods((store, booksService = inject(BooksService)) => ({
/* ... */
// 👇 Defining a method to load all books.
async loadAll(): Promise<void> {
patchState(store, { isLoading: true });
const books = await booksService.getAll();
patchState(store, { books, isLoading: false });
},
})),
);
Without a utility similar to a resource, we end up manually handling loading state, errors, and so on, which is heavy and tedious.
And in web apps, that is very common.
Why does it “work badly”? Because signalStore treats client state (its withState) as the source of truth. It cannot be derived from a resource, or from URL state. So it is forced into manual synchronization between external sources and internal state.
Some people like this pattern. I do not. I find the signalStore abstraction neither appropriate nor adaptable here.
That is not worthy of a Swiss Army knife. It has value, as we will see later, but not as a do-everything tool.
If you still doubt it, look at examples across articles. You will see they are very heavy for a pattern that has already been solved for a while by tools like resources, TanStack Query, and so on.
In my tools, I have a query utility that lets me treat the equivalent of a resource as the source of truth, and attach methods/computed values to interact with fetched state.
I use the same mechanism for query params with queryParam.
And from a DX perspective, it works much better.
But what about the next point?
3 - Testability and dependency tracking
On testability, signalStore is tested like any other service. So it has the same strengths and weaknesses as classic Angular services.
Another issue I did not mention, but that still bothers me even if the community accepts it:
Dependencies (injected services) are not tracked in signalStore.
Current Angular services share that weakness, and signalStore does not compensate for it.
Yet dependency tracking greatly simplifies testability (ensuring dependencies are properly provided or mocked).
Like standalone components where we import only what is needed, dependency tracking lets us provide and mock only what is necessary.
Remove a dependency? TypeScript throws an error in tests, because we are trying to provide/mock something that no longer exists.
That ensures tests fail for the right reasons, not because provider/mock configuration drifted.
I solved this in my tools too. If you are interested, check craftService.
The Swiss Army knife is starting to show some weaknesses...
Should we throw signalStore in the trash? No
Client state management
After exposing some limits, here is what it is good at, even very good at compared to most Angular state-management solutions.
It is very well suited to managing one client state.
I did say: one client state.
One:
So not several, but one single state.
Client:
So not server state, not state coming from a resource, not URL state.
Why not server state? Even though signalStore can be adapted to manage server state (API data), it introduces a lot of boilerplate in common cases, and it is easy to forget error handling. On top of that, with both Observables and Promises, errors are not typed, which makes business-error handling harder. Errors can come from lost connection, forbidden resource access, and so on. And if we look at the vast majority of API call examples, error handling is catastrophic (not to say the author just does not care). I think this comes from the fact that NgRx does not provide tools that truly help with API/async handling, and delegates that responsibility to users.
Why is it good for client states?
The value is that we start from a typed initial state.
That means the shape stays consistent, while properties can be updated.
And how are they updated?
Through methods that patch signalStore state.
Or via reactions to events.
And that is great: inside signalStore itself, we can see exactly how state evolves.
Exposing methods is practical, and it is just as easy to move to event-driven reactions, or a mix of both.
It can be a good compromise between a method-based approach and an event-based approach (Redux principle).
Another strength is exposing derived state directly through computed values.
Everything is in one place, which helps, in my view, to drive logic and visualize the different state evolutions.
Now let us talk about another “problem” signalStore solved compared to its predecessor (global store).
Global or local scope, but not quite...
A limitation of the global store is that it was mostly designed for global state. And it did that fairly well.
But when architecting an application, we need more than global state. We also need feature-local state, or even component-local state.
(I may be wrong on this point, feel free to correct me in the comments.)
Because signalStore can have either global or more local scope, it seems better suited to application architecture.
And that is to its credit.
But again, it does not do better than Angular services.
You can still forget to provide it and get a runtime error, which conceptually could be avoided.
Another criticism on this topic, where I think the NgRx team could adapt, is that it is not possible to use signalStore inline in a component the way we can with resources.
Yet that would give us a kind of enhanced signal where everything is declared explicitly about how the component interacts with it.
It would also remove issues around passing inputs and the requirement to provide the store.
It would greatly simplify creating one signalStore per state, rather than composing several states that are not necessarily related.
In my opinion, that should be the default mode.
And what about composition, which is often highlighted?
signalStore composition: top or flop?
In reality, I find it very interesting.
And it enables logic reuse in a very simple way.
However (yes, I have things to say here too 😂), allowing a store to be composed with features that each contain states, methods, computed values, and reactions is nice, but...
This choice can be very interesting for logic sharing, but it can also distort the purpose of the store.
We end up seeing signalStores used as facades.
Where signalStore adds no real value and even makes things more complicated.
As we can see in this example, the facade is handled by a classic service, which could also be a function (thanks to @lucas Garcia on LinkedIn for his critique of signalStore composition on the Bonjour Angular Discord, which inspired me).
@Service()
class MyFacade() {
public readonly state1 = inject(State1); // State1 created via signalStore
public readonly state2 = inject(State2); // State2 created via signalStore
public readonly derivedState = computed(() => {
// derivation logic based on state1 and state2
});
}
Where is the complexity? At what point does signalStore really add value when composing several different states?
Very little value.
My rule: signalStore manages one state, not several.
And we do not use it as a facade, or anything close to that.
Personally, to avoid these drifts, I prefer composing a state only through logic composition and derived states (no additional state added through composition). See my state primitive.
That keeps a clear responsibility for what our state-management “container” should do.
And I apply this principle to my other primitives too: query, mutation, queryParam, which, like Signal Form, derive logic from source state.
By the way, if you think the event system compensates for the other weak points, I also have one criticism there.
signalStore events are not that declarative
I clearly struggle with NgRx events, and the signalStore approach, while probably better than its parent, still has this major flaw.
We cannot derive an event (with official utilities). No, we must give it a description of what it represents.
That event is either dispatched after a user action, or from an effect that computes and then emits an event.
And these latter events are what bothers me.
Instead of going through a description, we could directly write the code that triggers the event at declaration time.
That gives us a much more declarative event, and avoids an effect that has to be provided to be active.
And on top of that, if it is not used, it can be tree-shaken by the compiler, which is not the case for a classic event.
After saying all this, I want to clarify that an event-based approach is still one of the most interesting approaches for managing state and business logic.
And it is harmful not to do event-based in a project just because events are not declarative.
Typing side
I will not go too deep into details, but even if the signalStore pattern is very interesting, and likely ahead of its time, it also has limits.
I have worked with it a lot. I rebuilt this whole accumulation mechanism because I found it fascinating.
Then I built server-state management tools dedicated to signalStore, and I can tell you: it is very, very complicated.
Personally, I think there is one layer too many in typing, which makes creating custom patterns extremely difficult.
And when we want to work with generic features based on the main withX pattern, if I remember correctly, it is not possible without using two utilities.
I had proposed a simplification for withFeature, but it was rejected because it does not handle generic-parameterized features better than their current mechanism.
Link to one of my articles on creating custom signalStore features with good DX.
I wanted to mention this point because it is part of signalStore’s limitations.
But it is not really the main problem.
Conclusion
signalStore enabled the discovery of a very interesting composition pattern.
It is king for managing one client state, where it offers good DX, and that is already valuable.
But as we saw, it is not a Swiss Army knife.
It does not replace a specialized tool for URL-related state or server states (current resources are not flawless either).
(craft-ng does all of this 🤫)
If you keep using it, my advice would be to always use it for one client state, and nothing more.
Avoid doing injections inside it as much as possible (still okay when interacting with the browser, for example API calls, local storage, etc.).
And do not use it for facades or composition/orchestration of several states.
That way, you keep the best parts of signalStore and can benefit from other patterns that are often more appropriate.
I am Romain Geffrault.
Angular developer and creator of @craft-ng
Follow me for more Angular content
Top comments (0)