The new NGRX Signal Store for Angular: 3+n Flavors

The NGRX team has been working on a store that fully leverages Signals. It's lightweight and extensible.

  1. The new NGRX Signal Store for Angular: 3+n Flavors
  2. Smarter, Not Harder: Simplifying your Application With NGRX Signal Store and Custom Features
  3. NGRX Signal Store Deep Dive: Flexible and Type-Safe Custom Extensions
  4. The NGRX Signal Store and Your Architecture

Since the advent of Signals, the NGRX team has been working on a store that leverages this new reactive building block. This new NGRX Signal Store was released with NGRX 17, is fully Signal-based, and highly extensible.

This article shows how to incorporate it in your application. For this, it shows up 3+1 different flavors of using it.

📂 Source Code

Getting the Package

To install the Signal Store, you just need to add the package @ngrx/signals to your application:

npm i @ngrx/signals

Flavor 1: Lightweight with signalState

🔀 Branch: arc-signal-store

A very lightweight way of managing Signals with the Signal Store is its signalState function (not to be confused with the signalStore function). It creates a simple container for managing the passed state using Signals. This container is represented by the type SignalState:

@Injectable({ providedIn: 'root' })

import { signalState } from '@ngrx/signals';

[...]

export class FlightBookingFacade {

    [...]

    private state = signalState({
        from: 'Paris',
        to: 'London',
        preferences: {
          directConnection: false,
          maxPrice: 350,
        },        
        flights: [] as Flight[],
        basket: {} as Record<number, boolean>,
    });

    // fetch read-only signals
    flights = this.state.flights;
    from = this.state.from;
    to = this.state.to;
    basket = this.state.basket;

  [...]
}

Each top-level state property gets its own Signal. These properties are retrieved as read-only Signals, ensuring a separation between reading and writing: Consumers using the Signals can just read their values. For updating the state, the service encapsulating the state provides methods (see below). This ensures that the state can only be updated in a well-defined manner.

Also, nested objects like the one provided by the preferences property above result in nested signals. Hence, one can retrieve the whole preferences object as a Signal but also its properties:

const ps = this.state.preferences();
const direct = this.state.preferences.directConnection();

Currently, this isn't implemented for arrays, as Angular's envisioned Signal Components will solve this use case by creating a Signal for each iterated item.

Selecting and Computing Signals

As the Signal Store provides the state as Signals, we can directly use Angular's computed function:

selected = computed(() =>
  this.flights().filter((f) => this.basket()[f.id])
);

Here, computed serves the same purpose as Selectors in the Redux-based NGRX Store: It
enables us to calculate different state representations for different use cases. These so-called View Models are only recomputed when at least one of the underlying signals changes.

Updating State

For updating the SignalState, Signal Store provides us with a patchState function:

import { patchState } from '@ngrx/signals';

[...]

updateCriteria(from: string, to: string): void {
  patchState(this.state, { from, to })
}

Here, we pass in the state container and a partial state. As an alternative, one can pass a function taking the current state and transforming it to the new state:

updateBasket(id: number, selected: boolean): void {
  patchState(this.state, state => ({
    basket: {
      ...state.basket,
      [id]: selected,
    },
  }));
}

Side Effects

Besides updating the state, methods can also trigger side effects like loading and saving objects:

async load() {
  if (!this.from() || !this.to()) return;

  const flights = await this.flightService.findPromise(
    this.from(),
    this.to()
  );

  patchState(this.state, { flights });
}

Decoupling Intention from Execution

Sometimes, the caller of patchState only knows that some state needs to be updated without knowing where it's located. For such cases, you can provide Updaters. Updaters are just functions taking a current state and returning an updated version of it:

type BasketSlice = { basket: Record<number, boolean> };
type BasketUpdateter = (state: BasketSlice) => BasketSlice;

export function updateBasket(flightId: number, selected: boolean): BasketUpdateter {
  return (state) => ({
    ...state,
    basket: {
      ...state.basket,
      [flightId]: selected,
    },
  });
}

It's also fine to just return a partial state. It will be patched over the current state:

type BasketSlice = { basket: Record<number, boolean> };
type BasketUpdateter = (state: BasketSlice) => BasketSlice;

export function updateBasket(flightId: number, selected: boolean): BasketUpdateter {
  return (state) => ({
    basket: {
      ...state.basket,
      [flightId]: selected,
    },
  });
}

If you don't need to project the current state, just returning a partial state is fine too. In this case, you can skip the inner function:

export function updateFlights(flights: Flight[]) {
  return { flights };
}

Updater can be defined in the Store's (signalState's) "sovereign territory". For the consumer, it is just a black box:

patchState(updateBasket(id, selected))

Passing an Updater to patchState expresses an intention. This is similar to dispatching an Action in the classic NGRX store. However, other than with Redux, no eventing is involved, and we cannot prevent the caller from directly passing their own Updaters. For the latter reason, I'm hiding the SignalStore behind a facade.

More: Angular Architecture Workshop (online, interactive, advanced)

Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!

All Details (English Workshop) | All Details (German Workshop)

Flavor 2: Powerful with signalStore

🔀 Branch: arc-signal-store-2

Similar to signalState, the signalStore function creates a container managing state with Signals. However, now, this container is a fully-fledged Store that not only comes with state Signals but also with computed Signals as well as methods for updating the state and for triggering side effects. Hence, there is less need for crafting a facade by hand, as shown above.

Technically, the Store is an Angular service that is composed of several pre-existing features:

export const FlightBookingStore = signalStore(
  { providedIn: 'root' },
  withState({
    from: 'Paris',
    to: 'London',
    initialized: false,
    flights: [] as Flight[],
    basket: {} as Record<number, boolean>,
  }),

  // Activating further features
  withComputed([...]),
  withMethods([...]),
  withHooks([...]),
)

In this case, the service is also registered in the root scope. When skipping { providedIn: 'root' }, one needs to register the service by hand, e. g., by providing it when bootstrapping the application, within a router configuration, or on component level.

Selecting and Computing Signals

The withComputed feature takes the store with its state Signals and defines an object with calculated signals:

withComputed((store) => ({
  selected: computed(() => store.flights().filter((f) => store.basket()[f.id])),
  criteria: computed(() => ({ from: store.from(), to: store.to() })),
})),

The returned computed signals become part of the store. A more compact version might involve directly destructuring the passed store:

withComputed(({ flights, basket, from, to }) => ({
  selected: selectSignal(() => flights().filter((f) => basket()[f.id])),
  criteria: selectSignal(() => ({ from: from(), to: to() })),
})),

Methods for Updating State and Side Effects

Similar to withComputed, withMethods also takes the store and returns an object with methods:

withMethods((state) => {
  const { basket, flights, from, to, initialized } = state;
  const flightService = inject(FlightService);

  return {
    updateCriteria: (from: string, to: string) => {
      patchState(state, { from, to });
    },
    updateBasket: (flightId: number, selected: boolean) => {
      patchState(state, {
        basket: {
          ...basket(),
          [flightId]: selected,
        },
      });
    },
    delay: () => {
      const currentFlights = flights();
      const flight = currentFlights[0];

      const date = addMinutes(flight.date, 15);
      const updFlight = { ...flight, date };
      const updFlights = [updFlight, ...currentFlights.slice(1)];

      patchState(state, { flights: updFlights });
    },
    load: async () => {
      if (!from() || !to()) return;
      const flights = await flightService.findPromise(from(), to());
      patchState(state, { flights });
    }     
  };
}),

withMethods runs in an injection context and hence can use inject to get hold of services. After withMethods was executed, the retrieved methods are added to the store.

Consuming the Store

From the caller's perspective, the store looks a lot like the facade shown above. We can inject it into a consuming component:

@Component([...])
export class FlightSearchComponent {
  private store = inject(FlightBookingStore);

  from = this.store.from;
  to = this.store.to;
  basket = this.store.basket;
  flights = this.store.flights;
  selected = this.store.selected;

  async search() {
    this.store.load();
  }

  delay(): void {
    this.store.delay();
  }

  updateCriteria(from: string, to: string): void {
    this.store.updateCriteria(from, to);
  }

  updateBasket(id: number, selected: boolean): void {
    this.store.updateBasket(id, selected);
  }
}

Hooks

The function withHooks provides another feature allowing to setup lifecycle hooks to run when the store is initialized or destroyed:

withHooks({
  onInit({ load }) {
    load()
  },
  onDestroy({ flights }) {
    console.log('flights are destroyed now', flights());
  },
}),

Both hooks get the store passed. One more time, by using destructuring, you can focus on a subset of the stores members.

rxMethod

🔀 Branch: arc-signal-store-rx

While Signals are easy to use, they are not a full replacement for RxJS. For leveraging RxJS and its powerful operators, the Signal Store provides a secondary entry point @ngrx/signals/rxjs-interop, containing a function rxMethod<T>. It allows working with an Observable representing side-effects that automatically run when specific values change:

import { rxMethod } from '@ngrx/signals/rxjs-interop';

[...]

withMethods(({ $update, basket, flights, from, to, initialized }) => {
  const flightService = inject(FlightService);

  return {
    [...]
    connectCriteria: rxMethod<Criteria>((c$) => c$.pipe(
      filter(c => c.from.length >= 3 && c.to.length >= 3),
      debounceTime(300),
      switchMap((c) => flightService.find(c.from, c.to)),
      tap(flights => patchState(state, { flights }))
    ))
  }
});

The type parameter T defines the type the rxMethod works on. While the rxMethod receives an Obserable<T>, the caller can also pass an Observable<T>, a Signal<T>, or T directly. In the latter two cases, the passed values are converted into an Observable.

After defining the rxMethod, somewhere else in the application, e. g. in a hook or a regular method, you can call this effect:

withHooks({
  onInit({ loadBy, criteria }) {
    connectCriteria(criteria());
  },
})

Here, the criteria Signal -- a computed signal -- is passed. Every time this Signal changes, the effect within connectCriteria is re-executed.

Custom Features - The Road Towards Further Flavors

🔀 Branch: arc-signal-store-custom

Besides configuring the Store with baked-in features, everyone can write their own features to automate repeating tasks. The playground provided by Marko Stanimirović, the NGRX contributor behind the Signal Store, contains several examples of such features.

One of the examples found in this repository is a CallState feature defining a state property informing about the state of the current HTTP call:

export type CallState = 'init' | 'loading' | 'loaded' | { error: string };

In this section, I'm using this example to explain how to provide custom features.

Defining Custom Features

A feature is usually created by calling signalStoreFeature. This function constructs a new feature on top of existing ones.

// Taken from: https://github.com/markostanimirovic/ngrx-signal-store-playground/blob/main/src/app/shared/call-state.feature.ts

import { computed } from '@angular/core';
import {
  signalStoreFeature,
  withComputed,
  withState,
} from '@ngrx/signals';

export type CallState = 'init' | 'loading' | 'loaded' | { error: string };

export function withCallState() {
  return signalStoreFeature(
    withState<{ callState: CallState }>({ callState: 'init' }),
    withComputed(({ callState }) => ({
      loading: computed(() => callState() === 'loading'),
      loaded: computed(() => callState() === 'loaded'),
      error: computed(() => {
        const state = callState();
        return typeof state === 'object' ? state.error : null
      }),
    }))
  );
}

For the state properties added by the feature, one can provide Updaters:

export function setLoading(): { callState: CallState } {
  return { callState: 'loading' };
}

export function setLoaded(): { callState: CallState } {
  return { callState: 'loaded' };
}

export function setError(error: string): { callState: CallState } {
  return { callState: { error } };
}

Updaters allows the consumer to modify the feature state without actually knowing how it's structured.

Using Custom Features

For using Custom Features, just call the provided factory when setting up the store:

export const FlightBookingStore = signalStore(
  { providedIn: 'root' },
  withState({ [...] }),

  // Add feature:
  withCallState(),
  [...]

  withMethods([...])
  [...]
);

The provided properties, methods, and Updaters can be used in the Store's methods:

load: async () => {
  if (!from() || !to()) return;

  // Setting the callState via an Updater
  patchState(state, setLoading());

  const flights = await flightService.findPromise(from(), to());
  patchState(state, { flights });

  // Setting the callState via an Updater
  patchState(state, setLoaded());
},

The consumer of the store sees the properties provided by the feature too:

private store = inject(FlightBookingStore);

flights = this.store.flightEntities;
loading = this.store.loading;

As each feature is transforming the Store's properties and methods, make sure to call them in the right order. If we assume that methods registered with withMethods use the CallState, withCallState has to be called before withMethods.

Flavor 3: Built-in Features like Entity Management

🔀 Branch: arc-signal-store-entities

The NGRX Signal Store already comes with a convenient extension for managing entities. It can be found in the secondary entry point @ngrx/signals/entities and provides data structures for entities but also several Updaters, e. g., for inserting entities or for updating a single entity by id.

To setup entity management, just call the withEntities function:

import { withEntities } from '@ngrx/signals/entities';

const BooksStore = signalStore(
  [...]

  // Defining an Entity
  withEntities({ entity: type<Flight>(), collection: 'flight' }),

  // withEntities created a flightEntities signal for us:
  withComputed(({ flightEntities, basket, from, to }) => ({
    selected: computed(() => flightEntities().filter((f) => basket()[f.id])),
    criteria: computed(() => ({ from: from(), to: to() })),
  })),

  withMethods((state) => {
    const { basket, flightEntities, from, to, initialized } = state;
    const flightService = inject(FlightService);

    return {
      [...],

      load: async () => {
        if (!from() || !to()) return;
        patchState(state, setLoading());

        const flights = await flightService.findPromise(from(), to());

        // Updating entities with out-of-the-box setAllEntities Updater
        patchState(state, setAllEntities(flights, { collection: 'flight' }));
        patchState(state, setLoaded());
      },

      [...],
    };
  }),
);

The passed collection name prevents naming conflicts. In our case, the collection is called flight, and hence the feature creates several properties beginning with flight, e.g., flightEntities.

There is quite an amount of ready-to-use Updaters:

  • addEntity
  • addEntities
  • removeEntity
  • removeEntities
  • removeAllEntities
  • setEntity
  • setEntities
  • setAllEntities
  • updateEntity
  • updateEntities
  • updateAllEntities

Similar to @ngrx/entities, internally, the entities are stored in a normalized way. That means they are stored in a dictionary, mapping their primary keys to the entity objects. This makes it easier to join them together to View Models needed for specific use cases.

As we call our collection flight, withEntities creates a Signal flightEntityMap mapping flight ids to our flight objects. Also, it creates a Signal flightIds containing all the ids in the order. Both are used by the also added computed signal flightEntities used above. It returns all the flights as an array respecting the order of the ids within flightIds. Hence, if you want to rearrange the positions of our flights, just update the flightIds property accordingly.

For building the structures like the flightEntityMap, the Updaters need to know how the entity's id is called. By default, it assumes a property id. If the id is called differently, you can tell the Updater by using the idKey property:

patchState(state, setAllEntities(flights, { collection: 'flight', idKey: 'flightId' }));

The passed property needs to be a string or number. If it's of a different data type or if it doesn't exist at all, you get a compilation error.

Conclusion

The upcoming NGRX Signal Store allows managing state using Signals. The most lightweight option for using this library is just to go with a SignalState container. This data structure provides a Signal for each state property. These signals are read-only. For updating the state, you can use the patchState function. To make sure updates only happen in a well-defined way, the signalState can be hidden behind a facade.

The SignalStore is more powerful and allows to register optional features. They define the state to manage but also methods operating on it. A SignalStore can be provided as a service and injected into its consumers.

The SignalStore also provides an extension mechanism for implementing custom features to ease repeating tasks. Out of the box, the Signal Store comes with a pretty handy feature for managing entities.

What's next? More on Architecture!

Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

free

Feel free to download it here now!