Signal Store: A Flexible Foundation for State Management
The introduction of signals in Angular has paved the way for building modern APIs around this reactive primitive, and state management is one of the key areas benefiting from this shift. A standout solution came from the NgRx team, spearheaded by Marko Stanimirovic, with the @ngrx/signals library. This approach has gained significant traction within the community due to its simplicity, flexibility, and effectiveness in handling both local and global state. The library provides utilities like signalStoreFeature, which enable scaling a store or creating reusable pieces. This extensibility has inspired the community to develop their own plugins, such as the @angular-architects/ngrx-toolkit suite.
Extending Signal Store for Integration
Given its robust design, one might consider Signal Store a complete state management solution. Its nature allows for adaptation to specific needs, including bridging to existing project stores or third-party libraries. An example of this is NGXS, where the team opted not to build an independent signal store, but instead provided utilities to connect their store with NgRx's Signal Store. This means applications using NGXS can now introduce NgRx Signal Store into their codebase with minimal friction. An official guide is available to walk through the setup process, which we will follow here.
What You Need to Get Started
NOTE: At the time this is written, the mentioned features are not yet officially released, so the API could change! To experiment, please use the development version (check with npm view @ngxs/store versions).
If @ngxs/store is already part of your project, the only additional requirement is adding Signal Store to your dependencies.
npm install @ngrx/signals
Next, you'll need to add a small, reusable code snippet to create a bridge between NGXS and Signal Store, enabling communication between them.
To achieve this, you'll work with two utility functions provided by NGXS:
- createSelectMap – takes an object whose values are selectors and converts them into signals
- createDispatchMap – transforms actions into invokable functions
These functions make it straightforward to construct custom adapters that link the NGXS global store with Signal Store. A significant advantage is that they are part of the core library, eliminating the need for extra sub-packages. Let's use them to create a shared integration file that is easily accessible and reusable across the entire application.
The initial adapter we'll build is withSelectors. This creates computed signals from the selectors you provide. It leverages the withComputed function to transform the output of createSelectMap into signal-based properties.
import { signalStoreFeature, withComputed } from '@ngrx/signals';
import { createSelectMap, SelectorMap } from '@ngxs/store';
export function withSelectors<T extends SelectorMap>(selectorMap: T) {
return signalStoreFeature(withComputed(() => createSelectMap(selectorMap)));
}
You can use it like so:
export const CounterStore = signalStore(
withSelectors({
counter: CounterSelectors.counter, // The value is a NGXS selector
}),
);
@Component({
selector: 'app-counter',
standalone: true,
providers: [CounterStore],
template: `
{{ counterStore.counter() }}
`,
})
export class CounterComponent {
readonly counterStore = inject(CounterStore);
}
The second adapter, withActions, generates methods that dispatch the specified actions. It relies on the withMethods function, retrieving a dispatcher map from createDispatchMap and converting it into Signal Store methods.
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { createDispatchMap, ActionMap } from '@ngxs/store';
export function withActions<T extends ActionMap>(actionMap: T) {
return signalStoreFeature(withMethods(() => createDispatchMap(actionMap)));
}
Adding it to our previous code example gives us:
export const CounterStore = signalStore(
withSelectors({
counter: CounterSelectors.counter,
}),
withActions({
increment: Increment, // The value is a NGXS action
}),
);
@Component({
selector: 'app-counter',
standalone: true,
providers: [CounterStore],
template: `
{{ counterStore.counter() }}
<button (click)="counterStore.increment()">Increment</button>
`,
})
export class CounterComponent {
readonly counterStore = inject(CounterStore);
}
These adapters are, in fact, Signal Store features designed for compatibility. They are built on the signalStoreFeature function, which is the core of Signal Store's extensibility, making this integration possible.
As you've noticed, these functions aren't provided out-of-the-box with NGXS; they need to be manually created. This intentional design keeps NGXS packages independent of NgRx dependencies. The official documentation explains why:
We chose not to tie our solution to NgRx signals because we aim to be solution-agnostic. Consequently, `createSelectMap` and `createDispatchMap` can be applied similarly with other state management libraries.
With this explanation and implementation in place, we're ready to proceed.
Is This Integration Actually Valuable?
Angular is heavily investing in signals, signaling a future where they become fundamental to building modern applications. As a result, numerous external libraries must adapt to this new paradigm.
Regarding signals in NGXS, it currently only offers the selectSignal API. While this is sufficient, there isn't much additional tooling available yet.
NGXS is typically used for global stores, meaning data shared across the entire app. Integrating a locally-scoped feature with the global store can feel excessive, and there's also a lack of API support for such integration. This is precisely where the @ngrx/component-store library shines, and Signal Store appears to be its natural modern replacement.
We can divide responsibilities between libraries – use NgRx for managing local state and NGXS for handling global state.
Concerned about bundle size from adding another library? Signal Store is quite lightweight, coming in at approximately 3.1kB minified and 1.2kB minified+gzipped.
Potential Drawbacks
Having two different state management libraries installed carries inherent risks. It adds complexity and increases the learning curve for developers new to the project. Managing different APIs and philosophies from separate libraries can be confusing. The functional style of Signal Store combined with the class-based approach of NGXS may clash in codebases that enforce strict coding patterns. Additionally, unit-testing becomes more challenging due to the extra architectural layer.
Hands-On Demo
We'll work with the todo application created by Fanis, which was featured in a previous article, and apply a light refactor. For a deeper understanding of how the app operates, reading the original post is recommended.
At present, there are two main ways to retrieve data from NGXS state:
- select method – returns an observable
- selectSignal method – returns a signal
@Component({
// ...
template: `
<!-- Signal -->
@for (todo of todos(); track todo.id) {
{{ todo.title }}
}
<!-- Observable -->
@for (todo of todos$ | async; track todo.id) {
{{ todo.title }}
}
`,
})
class TodoComponent {
private readonly store = inject(Store);
readonly todos$ = this.store.select(TodoSelectors.items); // Observable
readonly todos = this.store.selectSignal(TodoSelectors.items); // Signal
addTodo(title: string): void {
this.store.dispatch(new AddTodo(title));
}
}
Additionally, a facade pattern can be used to hide implementation details, grouping selectors and actions within a single class. To do this, we'll create an injectable service that is provided within the feature component.
@Injectable()
class TodoFacade {
private readonly store = inject(Store);
readonly todos$ = this.store.select(TodoSelectors.items); // Observable
readonly todos = this.store.selectSignal(TodoSelectors.items); // Signal
addTodo(title: string): void {
this.store.dispatch(new AddTodo(title));
}
}
@Component({
providers: [TodoFacade]
// ...
})
class TodoComponent {
readonly todoFacade = inject(TodoFacade);
}
This approach offers a clean, maintainable, and straightforward interface for components to interact with the store, wrapping the underlying business logic complexity.
This pattern works well, but let's see how it translates to Signal Store. Our new store could look like this:
const TodoStore = signalStore(
withSelectors({
todos: TodoSelectors.items,
}),
withActions({
addTodo: AddTodo,
changeStatus: ChangeStatus,
})
);
@Component({
providers: [TodoStore]
// ...
})
class TodoComponent {
readonly todoStore = inject(TodoStore);
}
What’s different? We no longer rely on explicit NGXS selectors. Instead, we import our custom adapters and chain them within the signalStore function. I find this solution quite elegant and readable. It performs similarly to our previous facade, but now we gain all the advantages of the signal store. To illustrate this, we'll utilize signal store features to dispatch toast messages. Since we prefer to keep this decoupled from the global store, our TodoStore, provided in the feature component, is the ideal place.
export const TodoStore = signalStore(
// ? Our adapters that we've created at the beginning of th article ?
withSelectors({
todos: TodoSelectors.items,
}),
withActions({
addTodo: AddTodo,
changeStatus: ChangeStatus,
}),
withComputed((store) => ({
todosCount: computed(() => store.todos().length),
})),
withHooks({
onInit(
{ todosCount },
actions$ = inject(Actions),
destroyRef = inject(DestroyRef),
toastrService = inject(ToastrService)
): void {
actions$
.pipe(
ofActionSuccessful(AddTodo),
tap({
next: () => {
toastrService.info(`Todo Added! Total count: ${todosCount()}`);
},
}),
takeUntilDestroyed(destroyRef)
)
.subscribe();
},
})
);
In the example above, the withComputed function is used to create a computed signal that tracks the number of todo items. This signal is then used in the onInit hook to listen for successful AddTodo actions and trigger a toast message.
Demo Playground:
https://stackblitz.com/edit/stackblitz-starters-tux39m
Final Thoughts
We've demonstrated that combining NGXS with Signal Store opens up new possibilities for managing local state. Signal Store effectively functions as an additional layer between NGXS's global store and the UI, with its flexibility pushing capabilities to new heights.
While adopting two different systems might seem complex and harder to learn initially, the benefits are clear. You gain a powerful toolkit that can handle a wide range of data management needs, from small to large scale. This aligns well with Angular's future direction, offering a path to building applications that are not just more manageable, but also faster and more reliable.
References:
https://github.com/ngxs/store/blob/master/docs/concepts/select/signals.md
