The new NGRX Signal Store for Angular: 2 + 1 Flavors

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

Since the advent of Signals, the NGRX team has been working on a store that leverages this new reactive building block. Besides internally managing the whole state with Signals, the NGRX Signal Store is envisioned to be lightweight and extensible.

Currently, there is an RFC and a playground provided by Marko Stanimirović, the NGRX contributor behind the Signal Store. A first official version shipped via npm is planned for a later point in time.

This article shows the current working version of the Signal Store in the context of an application. For this, it shows up 2+1 different flavors of using it.

đź“‚ Source Code (Branches: arc-signal-store)

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' })
export class FlightBookingFacade {

    [...]

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

    // fetch root signals as readonly
    flights = this.state.flights;
    from = this.state.from;
    to = this.state.to;
    basket = this.state.basket;

  [...]
}

Each top-level state branch gets its own Signal. For the sake of simplicity, I refer to them as slices. These slices can be retrieved as read-only Signals ensuring a separation between reading and writing: Consumers using the Signals can just read their values.

For writing, the SignalState provides an $update method. To prevent consumers from directly calling $update, it seems to be a good idea to hide the SignalState within a service like the facade shown above.

Since the latest playground version, nested objects like the preferences slice 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 doesn\'t work with objects within arrays.

Selecting and Computing Signals

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

selected = computed(() =>
  this.flights().filter((f) => this.basket()[f.id]),
  { equal: (a,b) => a === b }
);

However, this comes with a small after-taste: As the Signal Store is using Immutables, we can and should pass a proper equal function for improving performance. I\'ve already discussed this here. To relieve us from this task, the NGRX team provides a selectSignal function:

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

Basically, it just delegates to computed and passes an equal function checking for object identity, as shown before. In addition, there is also an overload resembling the definition of selectors in the classical NGRX store:

selected2 = selectSignal(
  this.flights, 
  this.basket, 
  (flights, basket) => flights.filter((f) => basket[f.id])
);

Updating State

For updating the SignalState, we can directly pass a partial state to its $update method:

updateCriteria(from: string, to: string): void {
    this.state.$update({ from, to });
}

The prefix $ prevents naming conflicts as the Signal Store also provides a property for each slice. 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 {
    this.state.$update((state) => ({
        ...state,
        basket: {
            ...state.basket,
            [id]: selected,
        },
    }));
}

Also, side effects can be encapsulated into functions:

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

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

    this.state.$update({ flights });
}

Decoupling Intention from Execution

You can actually decouple the caller of $update from the updating logic by providing Updaters. Updaters are just functions taking a current state (slice) 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,
    },
  });
}

Such an Updater could be defined in the Store\'s (SignalState\'s) "sovereign territory". For the consumer, it is just a black box:

this.store.$update(updateBasket(id, selected))

Passing an Updater to $update expresses an intention. This is similar to dispatching an Action in the classic NGRX store. However, other than with Redux, there is no eventing involved, and we cannot prevent the caller from directly passing their own Updater. 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 also creates a container managing state with Signals. In addition, signalStore takes several optional features like features for defining computed Signals or Updaters. This all results in a service that can be retrieved via DI:

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

  // Activating further features
  withSignals([...]),
  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 during bootstrap or on a component level.

Another option for using the store is making a service inherit from it. The following example taken demonstrates how this works and how to use the individual features as mixins:

@Injectable({ providedIn: 'root' })
export class FlightBookingStore extends signalStore(withState(initialState)) {
  [...]
}

Selecting and Computing Signals

The withSignals feature takes a portion of the managed state and defines an object with calculated signals:

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

Updating the Store

Similar to withSignals, withMethods also takes a portion of the state and the $update method:

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

  return {
    updateCriteria: (from: string, to: string) => {
      $update({ from, to });
    },
    updateBasket: (flightId: number, selected: boolean) => {
      $update({
        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)];

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

It runs in an injection context and hence can use inject to get hold of services. The result is an object with methods provided by the store.

Consuming the Store

Registering computed Signals with withSignals and methods with withMethods makes the resulting store look like the above-used facade. 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);
  }
}

You can even get rid of the delegation code by making the store public and using it directly in the template in this simple case.

Hooks

Another feature allows to setup lifecycle hooks running when the store is initialized or destroyed:

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

Both hooks can take a subset of the store\'s methods and properties.

rxEffects

While Signals are easy to use, they are no replacement for RxJS. For leveraging RxJS and its powerful operators, the Signal Store provides the rxEffect<T> function. It allows defining an RxJS pipe representing effects that automatically run when the passed properties change:

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

  return {
    loadBy: rxEffect<{ from: string; to: string }>(
      pipe(
        debounceTime(initialized() ? 300 : 0),
        switchMap((c) => flightService.find(c.from, c.to)),
        tap((flights) => $update({ flights, initialized: true }))
      )
    ),
  }
});

Calling rxEffect<T> results in a method that can be registered using withMethods. As an alternative, it can also be used directly. The parameters to pass are defined via the type parameter T. This very type is also the type of the object passed through the pipe.

Now, somewhere else in the application, e. g. in a hook or a regular method, you can call this effect and pass the parameters as one Signal:

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

Every time the passed Signal changes, the effect is re-executed.

Custom Features - The Road Towards Further Flavors

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 slice 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 signalStoreFeatureFactory, returning a new feature represented by another function:

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

export function withCallState() {
  const callStateFeature = signalStoreFeatureFactory();

  return callStateFeature(
    withState<{ callState: CallState }>({ callState: 'init' }),
    withSignals(({ callState }) => ({
      loading: selectSignal(() => callState() === 'loading'),
      loaded: selectSignal(() => callState() === 'loaded'),
      error: selectSignal(callState, (callState) =>
        typeof callState === 'object' ? callState.error : null
      ),
    }))
  );
}

When calling this feature function, one can pass further functions taking the managed state and transforming it. This transformation is typically about adding further properties or methods. In the example above, this is done by delegating to already existing features.

For the slices 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 } };
}

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 registered methods but also everywhere else the store is consumed:

load: async () => {
  if (!from() || !to()) return;
  $update(setLoading());
  const flights = await flightService.findPromise(from(), to());
  $update({ flights }, setLoaded());
},

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.

Upcoming Flavor 3: Entity Management

The extension mechanism provided for custom features is key for further flavors. For instance, the RFC envisions features for managing entities:

// Taken from: https://github.com/ngrx/platform/discussions/3796
import { signalStore } from '@ngrx/signals';
import { withEntities, addOne, deleteOne } from '@ngrx/signals/entities';

const BooksStore = signalStore(
  withEntities<Book>({ collection: 'book' }),
  withEntities<Author>({ collection: 'author' })
);

const booksStore = inject(BooksStore);

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 by specific use cases.

Predefined computed Signals allow reading the managed entities as arrays with objects. Hence, the consumer doesn\'t need to deal with the internal representation. Also, the RFC speaks about providing generic Updaters for managed entities.

Another example found in the playground adds possibilities known from @ngrx/data. It defines a feature for connecting services loading state from the backend into the store. Such services need to align with a predefined interface.

Conclusion

The upcoming NGRX Signal Store allows managing state using Signals. The most lightweight option of using this library is just to go with a SignalState container. This is a data structure providing a Signal for each feature slice and a $update method. To control the possible updates, 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 machanism for implementing custom features defining state and methods that are used together. The NGRX team plans to use this mechanism to support the management of entities and to connect services for loading and saving state by delegating to the backend.

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!