The Signal Store from NGRX - breakdown of the main concepts

andreibogdanfara

Andrei Bogdan Fara

Posted on July 22, 2024

The Signal Store from NGRX - breakdown of the main concepts

Characteristics

  • signal based
  • functional and declarative
  • used for local or global state management
  • extensible with custom features

How does it compare to the NGRX Global Store?

  • more lightweight and simplified API
  • don't have to worry as much about the data flow
  • seems harder to misuse, like reusing actions
  • easier to extend

The Creator of NGRX Signal Store, Marko Stanimirovic describes here NgRx SignalStore: In-Depth Look at Signal-Based State Management in Angular

Class-based state management limitations:

  • Typing: It’s not possible to define dynamic class properties or methods that are strongly typed
  • Tree-shaking: Unused class methods won’t be removed from the final bundle
  • Extensibility: Multiple inheritance is not supported.
  • Modularity: Splitting selectors, updaters, and effects into different classes is possible, but not provided out of the box

Let’s explore the store's API with code examples. We’ll use a project with a list of product and filtering features.

Creating a SignalStore

  • signalStore function which returns an injectable service suitable to be injected and provided where needed to be used.
import { signalStore } from "@ngrx/signals";

export const ProductStore = signalStore(  );
Enter fullscreen mode Exit fullscreen mode

Providing State withState

As with any NGRX Store so far there is an initial state that can be provided, using the function withState which accepts object literals, records or factory functions (for creating a dynamic initial state) as inputs.

import { signalStore, withState } from "@ngrx/signals";

const initialProductState: ProductState = { products: [] };

export const ProductStore = signalStore(
 withState(initialProductState);
);
Enter fullscreen mode Exit fullscreen mode

Computed State withComputed

  • built on top of the computed function to create derived states (computed state) from the store
import { signalStore, withComputed, withState } from "@ngrx/signals";

export const ProductStore = signalStore(
 withState(initialProductState),
 withComputed(({products}) => ({
   averagePrice: computed(() => {
     const total = products().reduce((acc, p) => acc + p.price, 0);
     return total / products().length;
   })
 })),
Enter fullscreen mode Exit fullscreen mode

Performing Operations withMethods

  • it’s the place where the store’s operations will be defined
  • these can be methods to update the store or perform some operations based on its current state
import { signalStore, withComputed, withState, withMethods } from "@ngrx/signals";

export const ProductStore = signalStore(
 withState(initialProductState),
 withComputed(({products}) => ({
   averagePrice: computed(() => {
     const total = products().reduce((acc, p) => acc + p.price, 0);
     return total / products().length;
   })
 })),


 // CRUD operations
 withMethods((store,
   productService = inject(ProductService),
 ) => ({
   loadProducts: () => {
     const products = toSignal(productService.loadProducts())
     patchState(store, { products: products() })
   },
   addProduct: (product: Product) => {
     patchState(store, { products: [...store.products(), product] });
   },
   // ...
 })),
Enter fullscreen mode Exit fullscreen mode

withMethods & withComputed get in a factory function and return a dictionary of methods and computed signals that can be accessed by using the store. They also run in an injection context, which makes it possible to inject dependencies into them.

Hooking withHooks

  • lifecycle methods of the store, currently it has onInit and onDestroy methods

import { withHooks } from "@ngrx/signals"; 

export const ProductStore = signalStore(
 withHooks((store) => ({
   onInit() {
     // Load products when the store is initialized
     store.loadProducts();
   },
 })),
);

Enter fullscreen mode Exit fullscreen mode

Managing Collections withEntities

  • use it when having to manage data like “Products, Users, Clients, etc” where CRUD operations are necessary for that feature
  • it provides a set of APIs to manage collections, like: addEntity, setEntity, remoteEntity.
export const ProductStoreWithEntities = signalStore(
 withEntities<Product>(),


 // CRUD operations
 withMethods((store,
   productService = inject(ProductService),
 ) => ({
   loadProducts: () => {
     const products = toSignal(productService.loadProducts())();
     patchState(store, setAllEntities(products || []));
   },
   updateProduct: (product: Product) => {
     productService.updateProduct(product);
     patchState(store, setEntity(product));
   },

 })),
Enter fullscreen mode Exit fullscreen mode

It’s possible to add multiple features which start with “with” but they can access only what was defined before them.

Creating custom features with signalStoreFeature

signalStoreFeature - used for extending the functionality of the store.

Stores can get complex and hard to manage for big enterprise applications. When writing features and components for a project, the better and more granular the split, the easier to manage, maintain the code and write tests for it.

However, considering the API that SignalStore provides the store can get hard to manage unless code is splitted accordingly. signalStoreFeature is suitable for extracting specific functionality of a feature (or component) into a standalone testable function which potentially (and ideally) can be reused in other stores.

export const ProductStore = signalStore(
 // previous defined state and methods

 // Externalizing filtering options
 withFilteringOptions(),
);


export function withFilteringOptions() {
 return signalStoreFeature(
  // Filtering operations
 withMethods(() => ({
   getProductsBetweenPriceRange: (lowPrice: number, highPrice: number, products: Array<Product>, ) => {
     return products.filter(p => p.price >= lowPrice && p.price <= highPrice);
   },


   getProductsByCategory: (category: string, products: Array<Product>) => {
     return products.filter(p => p.category === category);
   },
 })),
 );
}
Enter fullscreen mode Exit fullscreen mode

Now an example of signalStoreFeature that shows the possibility to reuse signalStoreFeature(s) across multiple stores.

import { patchState, signalStoreFeature, withMethods } from "@ngrx/signals";

export function withCrudOperations() {
 return signalStoreFeature(
   withMethods((store) => ({
     load: (crudService: CrudOperations) => crudService.load(),
     update: (crudableObject: CRUD, crudService: CrudOperations) => {
       crudService.update(crudableObject);
       patchState(store, setEntity(crudableObject));
     },
   }),
 ));
}

export interface CrudOperations {
 load(): void;
 update(crudableObject: CRUD): void;
}

// Product & Customer services must extend the same interface.

export class ProductService implements CrudOperations {
 load(): void {
   console.log('load products');
 }
 update(): void {
   console.log('update products');
 }
}

export class CustomerService implements CrudOperations {
 load(): void {
   console.log('load customers');
 }
 update(): void {
   console.log('update customers');
 }
}

// and now let’s add this feature in our stores

export const ProductStore = signalStore(
 withCrudOperations(),
);


export const CustomerStore = signalStore(
 withCrudOperations(),
);
Enter fullscreen mode Exit fullscreen mode

NGRX Toolkit utility package

Being that easy to extend, there is already a utility package called ngrx-toolkit meant to add useful tools to Signal Stores.

Injecting SignalStore

{ providedIn: ‘root’ } or in the providers array of a Component, Service, Directive, etc.

DeepSignals

  • nested state properties read as signals, generated lazily on demand

patchState

  • alternative API to set and update (of signal API) for updating store’s state, only needs to be provided the values we want to change

rxMethod

  • utility method which helps use RxJS together with the SignalStore or signalState

Lighter alternative with SignalState

  • SignalState provides an alternative to managing signal-based state in a concise and minimalistic manner.

Concluding thoughts

It remains to be proven how reliable it is for larger applications, especially when applied as a global store.

For now I think it's a great addition to the default Signal API, making it a good option for managing:

  • component level state
  • feature based state

Additional Resources:

https://www.stefanos-lignos.dev/posts/ngrx-signals-store
https://www.angulararchitects.io/blog/the-new-ngrx-signal-store-for-angular-2-1-flavors/ (group of 4 articles on the topic)
https://ngrx.io/guide/signals

💖 💪 🙅 🚩
andreibogdanfara
Andrei Bogdan Fara

Posted on July 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related