State management is a cornerstone of modern Angular application development, ensuring consistency, scalability, and maintainability of the application state. While the traditional NgRx Store has long been a go-to solution, NgRx SignalStore emerges as a modern alternative that leverages Angular’s reactivity model through signals.
In this article, we’ll explore what NgRx SignalStore offers, when and why to use it, how it compares to other NgRx libraries, its limitations, and best practices for getting started.
What is NgRx SignalStore?
NgRx SignalStore is an Angular library designed to simplify state management by integrating signals, a new reactivity primitive introduced in Angular. It replaces boilerplate-heavy patterns with a more direct and signal-first approach.
Key Features:
- Signal-based reactivity: Works seamlessly with Angular’s Signals, reducing boilerplate for reactivity.
- Minimalistic API: Simplifies state and computed properties management.
- Encapsulation-first design: Keeps logic and state close, leading to better modularity.
- Custom Features: Allows for lifecycle hooks, deep computed properties, and private store members.
For a detailed overview, visit the NgRx SignalStore documentation.
SignalStore is made up of 3 core features and can be defined much more concisely than NgRx Store.
State
withState
describes the initial state slices of the store. This is similar to what you would provide to a reducer in NgRx Store.
const initialState: BooksState = { books: [], isLoading: false, filter: { query: '', order: 'asc' }, }; export const BooksStore = signalStore( withState(initialState) // 👈 );
These state slices are now accessible as Signals, and nested properties are accessible as a DeepSignal
. Notice that you don’t have to create a selector for each slice of state as with NgRx Store.
<p>Query: {{ store.filter.query() }}</p>
Derived State
withComputed
defines computed signals that utilize previously defined state computed signals. These signals will automatically update when dependencies change. With NgRx Store, this is similar to using selectors to combine state slices or derive state from your store.
export const BooksStore = signalStore( withState(initialState), withComputed(({ books, filter }) => ({ // 👈 booksCount: computed(() => books().length) }) )
Updating State
withMethods
allows you to update state or handle asynchronous side effects using Promise-based APIs or Observables. Within the factory function, you have access to the store instance, including all previously defined state, computed signals, and methods. To update the state, you can use the provided patchState
function—a pattern that will feel familiar to developers accustomed to the Redux paradigm or hooks.
export const BooksStore = signalStore( withState(initialState), withComputed(/* ... */), withMethods((store) => ({ // 👈 updateQuery(query: string): void { patchState(store, (state) => ({ filter: { ...state.filter, query } })); }, })) );
When and Why Should You Use NgRx SignalStore?
NgRx SignalStore is ideal for developers who:
- Want simpler state management: No need to implement actions, reducers, or effects.
- Rapid Prototyping: Simplified setup accelerates development.
- Develop small to medium-sized applications: Lower overhead compared to full Redux-patterned libraries.
- Isolated State: Excellent for self-contained features like filters or modals.
- Build mostly isolated features: No need to connect multiple interconnected feature states with time based events.
When Not to Use It?
- Complex State Orchestration: If your application depends on cross-feature orchestration, NgRx Store is better suited.
- Debugging Requirements: Lack of built-in tools like Redux DevTools may hinder workflows in large apps or when time-travel debugging is needed. An open-source library ngrx-toolkit does exist for this, though.
How Does NgRx SignalStore Differ from NgRx Store ?
Feature | NgRx Store | NgRx SignalStore |
---|---|---|
Pattern | Redux pattern with strict separation of concerns (actions, reducers, effects). | Signals-based, reducing boilerplate overhead |
Learning Curve | Steeper: Requires understanding of actions, reducers, effects | Shallow: State and logic colocated for simplicity |
Debugging | Excellent Redux DevTools support | Limited: Manual solutions needed |
Async Handling | Built-in via Effects | Requires custom methods or RxJS integration |
Best Use Case | Large, interdependent systems with complex workflows | Feature-isolated state or smaller apps |
Where SignalStore Falls Short
Managing Side Effects
SignalStore supports asynchronous operations through Promise-based APIs and RxJS, providing flexibility for handling async logic. However, it lacks the structured orchestration provided by NgRx Effects, which is designed specifically for managing complex side effects in a clear and predictable manner. This can make it more challenging to implement and maintain intricate asynchronous workflows, especially in applications requiring coordinated updates across multiple state slices or features.
Orchestrating Complex State
SignalStore excels in encapsulating state into smaller, self-contained stores. However, in applications with highly interdependent features, orchestrating events or state changes across multiple stores can become cumbersome. The absence of a centralized action system (like NgRx Store’s actions) makes it harder to coordinate global state updates.
Additionally, SignalStore is inherently tied to Angular’s change detection. While this coupling simplifies small applications, it can become restrictive in larger applications where finely tuned performance and independent state orchestration are required.
Tracking State Over Time
Unlike NgRx Store, SignalStore does not support actions as first-class citizens. Actions in NgRx Store allow developers to log, replay, and analyze changes over time, enabling robust debugging and testing. SignalStore’s focus on simplicity means it relies on direct state updates, which can limit debugging and time-travel capabilities. If your application needs to dispatch actions across multiple features or track state changes over time, NgRx Store remains a better fit.
Best Practices for Using NgRx SignalStore
Structure SignalStores Appropriately
Organize state into smaller, manageable SignalStores for each feature or domain to avoid a monolithic state structure.
Use custom SignalStore features to extend core functionality, encapsulate common patterns, or facilitate reuse across multiple stores.
Utilize methods just like you would effects for async state changes.
Utilize the factory function with withState
if you need the initial state to be obtained from a service or injection token.
const BOOKS_STATE = new InjectionToken<BooksState>('BooksState', { factory: () => initialState, }); const BooksStore = signalStore( withState(() => inject(BOOKS_STATE)) );
Use a function that returns an object within withMethods
if you need to call other methods. Previous examples have defined the methods like this:
withMethods((store) => ({ setLoading(loading: boolean): void { patchState(store, { loading }); }, updateOrder(order: 'asc' | 'desc'): void { patchState(store, (state) => ({ filter: { ...state.filter, order } })); }, }))
However, you can also explicitly return the methods, which allows you to create private methods or have a method call other methods, as seen below.
withMethods(store => { function setLoading(loading: boolean): void { patchState(store, { loading }); // 👇 call another method updateQuery(/* ... */); } function updateQuery(query: string): void { patchState(store, (state) => ({ filter: { ...state.filter, query } })); } // 👇 Expose accessbile methods from the store return { setLoading, updateQuery }; })
Use Computed Properties
For derived state, use computed signals to minimize redundant calculations (docs). However, with SignalStore every state slice is a signal so you don’t need to create a “selector” for every slice of state like you do with NgRx Store.
Asynchronous updates
Methods encapsulate both synchronous and asynchronous state updates. Abstracting network calls and other async methods into other methods helps with adhering to the D.R.Y. principle. However, those with experience using NgRx Effects must rethink their strategy to combine state changes with asynchronous logic. Fortunately, Promises-based APIs and Observables can be used in Methods.
withMethods((store, api = inject(HackerNewsDataService)) => ({ loadNewestStories: rxMethod<void>( // `rxMethod` for managing side effects pipe( switchMap(() => // any normal RxJs async logic api .loadStoryEntitiesRange({ start: 0, end: 30 }) .pipe(tap(stories => patchState(store, addEntities(stories)))) ) ) ),
Entity Management
NgRx provides a signal-based package called @ngrx/signals/entities
that is beneficial for maintaining a normalized state structure, especially in CRUD-heavy applications. As you might expect, its offering is similar to @ngrx/effects
. Setting it up is similar to the other SignalStore features, and it’s just as easy to get started.
type Todo = { id: number; text: string; completed: boolean; }; export const TodosStore = signalStore( withEntities<Todo>() // add entity feature );
The entity feature also supports multiple entities, custom entity identifiers, and private entity collections.
import { addEntity, entityConfig, withEntities } from '@ngrx/signals/entities'; type Todo = { key: number; text: string; completed: boolean; }; const todoConfig = entityConfig({ entity: type<Todo>(), // type inference collection: 'todo', // custom name, for multiple entities selectId: (todo) => todo.key, // custom entity identifier }); export const TodosStore = signalStore( withEntities(todoConfig), // add entity feature withMethods((store) => ({ addTodo(todo: Todo): void { patchState(store, addEntity(todo, todoConfig)); // uses `addEntity` to from @ngrx }, })) );
Can I Use NgRx Store and NgRx SignalStore Together?
Yes, you can use NgRx Store and NgRx SignalStore together in the same application. Both libraries serve distinct purposes and can complement each other when used thoughtfully.
When to Use Both?
- Complex Global State + Simple Local State
Use NgRx Store to manage complex global state, such as shared application-wide data or events requiring centralized control (e.g., user authentication, notifications). Meanwhile, leverage NgRx SignalStore to manage more localized or feature-specific state where simplicity and encapsulation are beneficial. - Gradual Migration
If your application is built entirely with NgRx Store but you want to introduce SignalStore for simpler, isolated features, you can migrate incrementally without refactoring the entire state management structure. - State Aggregation
Use NgRx SignalStore for encapsulating smaller feature states and combine them into a global state managed by NgRx Store when necessary. This approach can simplify scaling complex applications.
Best Practices for Combining
- Clearly define boundaries: Decide which parts of your application use NgRx Store versus NgRx SignalStore to avoid confusion.
- Minimize duplication: Avoid managing the same slice of state in both libraries. Instead, share data as needed through signals or selectors.
By combining these libraries, you can harness the power of NgRx Store’s robust global state management and SignalStore’s streamlined simplicity for feature-specific state.
Conclusion
NgRx SignalStore offers a streamlined and signal-driven approach to state management in Angular. It shines in feature-specific stores, isolated state, and rapid prototyping but may fall short for highly complex, interconnected applications.
Experiment with SignalStore to determine if it fits your needs. By adopting the right patterns, SignalStore can significantly enhance the maintainability and performance of your Angular applications.