Stop using SignalStore for everything: hype fades, debt remains.
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 Angular integration. 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. Let me tell you a story about a young Angular enthusiast, Simon. At work, signalStore is used everywhere, and we ended up discussing 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 component/page reads the id as an input (using withComponentInputBindings). We use a resource and pass this id signal as params. Whenever it changes, it automatically triggers an API call for the new id. Nothing complicated. This is a very standard resource use case. // component/page @Component() class UserDetails { id = input.required(); userService = inject(UserService); userDetailsResource = resource({ params: this.id, loader: (id) => this.userService.getUserDetails(id), }); } Simon wanted to avoid handling this logic directly in the component to “separate responsibilities,” and that fit the rest of the project better. In other words, he wanted to go through signalStore to make the API call. Have you seen signalStore examples for API calls? 😅 Have you noticed how server state is usually handled in signalStore when following the official NgRx examples? We will come back to that. For now, the expected behavior is simple: when the id in the URL changes, call the API to fetch new User details. Here is one possible solution (many variants exist, but the underlying issue is the same): On the signalStore side, we can imagine an implementation like this. It appears to match the expected behavior. 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 this step was so hard for him. 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: expose a method that updates 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 that updates id in signalStore state })), ); On the component side, we need an effect that calls this method whenever id changes. @Component({ providers: [UserDetailsStore] }) // create a store instance for this component, destroyed with the component class UserDetails { id = input.required(); userDetailsStore = inject(UserDetailsStore); _syncIdWithStore = effect(() => { this.userDetailsStore.setId(this.id()); }); } That is quite heavy just to extract logic out of a component, right? PS: Using an effect here is appropriate for this solution. We have to provide the store, inject it, create an effect to sync component id with store id, and expose a store method to update id. After that, I told him: wait, I will show you another way to extract logic from a component, and it is much simpler. function userDetailsResource(id: Signal) { const userService = inject(UserService); return resource({ params: id, loader: ({ params: id }) => userService.getUserDetails(id), }); } @Component() class UserDetails { id = input.required(); userDetails = userDetailsResource(this.id); } And that is it. Yes, this is not a 1:1 port because you cannot inject userDetails into child components. But there was never anything difficult about writing a function that extracts logic from the component. And we do not see this pattern often, even though Signals made it much simpler once inputs became signals. If you really want to keep the dependency injection approach to share it with child components, you can also do that using tools I built (craft-ng). export const { injectUserDetailsQuery, provideUserDetailsQuery } = craftService( { name: "UserDetailsQuery", scope: "toProvid" }, (id: Signal) => { const userService = inject(UserService); return resource({ params: id, loader: (id) => userService.getUserDetails(id), }); }, ); @Component({ providers: [provideUserDetailsQuery()] }) class UserDetails { id = input.required(); userDetailsQuery = injectUserDetailsQuery(this.id); } There is barely any added complexity here. As we saw, you need a lot of boilerplate to connect an input to signalStore. That adds mental overhead to understand the data flow and what is happening in the app. You can create helpers to simplify binding an input to signalStore. I have shared different versions in my posts. I was generous and used resources to manage server state (user details) inside signalStore. If we look at the official example that handles server state directly with signalStore: export const BookSearchStore = signalStore( withState(initialState), withMethods((store, booksService = inject(BooksService)) => ({ /* ... */ // 👇 Defining a method to load all books. async loadAll(): Promise { patchState(store, { isLoading: true }); const books = await booksService.getAll(); patchState(store, { books, isLoading: false }); }, })), ); Without a helper similar to resource, you end up manually handling loading state, errors, and so on, which is heavy and repetitive. And that is extremely common on the web. Why does it feel wrong? Because signalStore treats client state (its withState) as the source of truth. It cannot be derived from a resource or from URL-driven state. So it pushes you toward forced synchronization between external sources and its internal state. Some people like this pattern. I do not. I find the signalStore abstraction neither well-suited nor truly adaptable for this use case. That is not what an all-purpose Swiss Army knife should be. It does have value, as we will see, but not as a do-everything tool. If you still doubt it, look at examples in articles. Most are very heavy for a pattern that has already been solved by tools like resources, TanStack Query, and others. In my tools, I have a query helper that lets me treat the equivalent of a resource as the source of truth, then attach methods/computed values to interact with the fetched state. I use the same mechanism for query params via queryParam. From a DX perspective, it works much better. But what about the next point? For testing, signalStore is tested like any Angular service. So it inherits the same strengths and the same weaknesses as classic Angular services. There is another issue I have not mentioned yet, but it bothers me even though the community accepts it. In signalStore, injected dependencies are not tracked. Current Angular services have the same weakness, and signalStore does not compensate for it. Yet tracking dependencies greatly improves testability: you can ensure required dependencies are provided, or properly mocked. Like standalone components where you import only what is needed, dependency tracking lets you provide and mock only what is needed. If you remove a dependency, TypeScript immediately flags tests that still provide/mock a dependency that no longer exists. That makes tests fail for the right reasons, not because provider/mock configuration drifted. I also solved this in my tools. If you are interested, check out craftService. The Swiss Army knife is starting to show some weaknesses. After highlighting limits, here is what it does well, even very well compared to most Angular state-management solutions. It is very well suited to managing one client state. I said one client state. One: Client: Why not server state? Although signalStore can be adapted for server state (API data), it introduces a lot of boilerplate for common cases, and error handling is easy to forget. 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 access, and so on. And if you look at most API-call examples, error handling is often terrible (sometimes effectively ignored). I think this happens because NgRx does not provide a real abstraction to help with API/async handling, and leaves that responsibility to users. Why is it good for client state? Because you start from a typed initial state. This means the shape stays consistent, while properties can be updated. And how are they updated? Through methods that patch signalStore state. Or through reactions to events. And that is great. Inside signalStore itself, you can clearly see how state evolves. Exposing methods is practical, and it is just as easy to move to event-driven reactions, or combine both. It can be a good middle ground between method-based and event-based approaches (Redux principle). Another benefit is exposing derived state directly with computed values. Everything is in one place, which helps, in my opinion, to drive logic and visualize all the ways state can evolve. Now, let us talk about another “problem” signalStore solved compared to its predecessor (global store). One limitation of the global store was that it mainly targeted global state management. And it did that fairly well. However, when designing an application, you do not only need global state. You also need feature-local state, or even component-local state. (I may be wrong on some details here, feel free to correct me in the comments.) Because signalStore can have either global or more local scope, it looks better suited for app architecture. 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, even though conceptually this could be prevented. Another related criticism, and I think the NgRx team could adapt on this, is that signalStore cannot be used inline inside a component the way resources can. Yet that would let us treat it like an enhanced signal where the component interaction model is fully explicit. It would also remove issues around input passing and mandatory provider setup. It would make creating one store per state much simpler, instead of packing several loosely related states together. In my opinion, that should be the default mode. Speaking of composition, often presented as a big strength... In reality, I find it very interesting. It enables logic reuse very easily. However (yes, I have things to say here too 😂), being able to compose a store with features that each include state, methods, computed values, and reactions is powerful, but... This design can be great for sharing logic, but it can also distort the purpose of a store. You end up seeing signalStores used as facades. Where signalStore adds no real value and actually makes things harder. As in this example, the facade is handled by a classic service, which could also be a function (credit: @lucas Garcia on LinkedIn). @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 here? At what point does signalStore really add value when composing several different states? Almost none. My rule: signalStore manages one state, not several. And I 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 state (no extra base state added through composition). See my state primitive. That keeps a clear responsibility for what our state-management “container” should do. And I apply the same principle to my other primitives query, mutation, and queryParam, which, similar to Signal Form, derive behavior from source state. Also, if you think the event system compensates for the weak points above, I have one more criticism. I struggle with NgRx events in general, and while signalStore likely improves over its predecessor, it still has this major flaw. You cannot derive an event with official utilities. Instead, you 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 those effect-driven events are what bother me. Instead of going through a separate description, we could directly write the event-triggering code where the event is declared. That would make events much more declarative and avoid effects that need to be provided just to be active. And if an event is unused, it could be tree-shaken by the compiler, unlike a classic event setup. After saying all this, I still want to be clear: event-based architecture remains one of the most interesting approaches for state and business logic. And skipping event-based patterns entirely just because events are not declarative would be a mistake. I will not go too deep, but even though signalStore’s underlying pattern is very interesting and likely ahead of its time, it also has limits. I worked with it a lot. I reimplemented the whole accumulation mechanism because I found it fascinating. I then built server-state management tools specifically for signalStore, and I can tell you: it is very, very complicated. Personally, I think there is one typing layer too many, which makes custom patterns extremely hard to build. And when you want to work with generic features based on the main withX pattern, from what I remember, you cannot do it without two utility helpers. I proposed a simplification for withFeature, but it was rejected because it did not handle generic-parameter features better than their current mechanism. Link to one of my articles about building custom signalStore features with good DX. I wanted to mention this because it is part of signalStore’s limitations. But it is not the core issue. signalStore introduced a very interesting composition pattern. It is excellent for managing one client state and provides good DX, which is already valuable. But as we saw, it is not a Swiss Army knife. It does not replace specialized tools for URL-related state or server state (and current resources are not flawless either). (craft-ng handles all that 🤫) If you keep using signalStore, my advice is: use it to manage one client state, no more. Avoid injecting dependencies into it as much as possible (it is still fine when interacting with the browser, for example API calls, local storage, etc.). And do not use it as a facade or as an orchestrator that composes several states. That way, you keep its best strengths and can combine it with other patterns that are often more appropriate. I am Romain Geffrault. Docs: https://ng-angular-stack.github.io/craft/
