Back to the homepage
Angular

Signal Store & NGXS: Elevating Flexibility in State Management

Since the introduction of signals in Angular, it has opened new opportunities to build new APIs around it, including state management. The best solution for that was built by the NgRx team, mainly by Marko Stanimirovic who introduced their implementation of signal-based state management@ngrx/signals library. The solution was widely accepted by the community as the idea is simple, yet very flexible and effective. It can be used for managing both local and global state. The library offers utility tools (like signalStoreFeature) that help us scale our store or build shareable pieces. That allowed the community to build their own extensions, like @angular-architects/ngrx-toolkit with a bunch of helpful plugins.

Signal Store extendability

That being said, we can think that Signal Store is somewhat of a complete state management library. Because of its nature, we can adopt the store, based on our needs. That also includes creating adapters to internal project stores or third-party solutions. One of which is NGXS, whose authors decided to not create their own implementation of signal store, but rather create a set of utilities that allow connecting @ngxs/store with Signal Store from NgRx. Essentially, applications that are based on the NGXS store can now integrate the NgRx Signal Store to their codebases with minimal effort. The NGXS team has provided an official guide showing how to set up this integration, which we’ll get through. 

Prerequisites

NOTE: At the time of publication, all of the described features have not  been released yet, so the API may change! If you wanna play with them, make sure to use the dev release version (use npm view @ngxs/store versions).

Assuming we already have @ngxs/store installed, we only need to add Signal Store to our dependencies.

npm install @ngrx/signals

We need to integrate a small reusable code snippet into our codebase to create a bridge between NGXS and Signal Store that allows communication between these two libraries.

To make it work we have to use two utility functions that are exposed by NGXS:

  • createSelectMap – takes an object input with selectors as values to produce signals out of them
  • createDispatchMap – wraps actions into callable functions

These functions allow us to create custom adapters that link the NGXS global store to the Signal Store. The best part is that they are included in the main library, so there is no need to install any additional sub-packages to use them. Let’s use these functions to create our integration within a file that’s easily accessible, making it reusable throughout the entire application.

The first adapter is gonna be withSelectors. This adapter creates computed signals from the provided selectors. It utilizes the withComputed function to wrap the output of createSelectMap into signal 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)));
}

We can use it in as follows:

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 other adapter is withActions that will create methods that dispatch the provided actions. It uses the withMethods function, which retrieves a dispatcher map from createDispatchMap and converts 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)));
}

Now we can add it to our previous code example:

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);
}

Our adapters are actually features of the Signal Store, designed to ensure compatibility with it. They utilize the signalStoreFeature function, which is the primary driver of the Signal Store’s extensibility system, enabling our integration. 

As you already noticed, we have to create these functions manually. They don’t come out of the box in NGXS. That simply allows NGXS packages to not rely on NgRx dependencies. We can find the official reason in their documentation: 

The reason we didn’t tie our solution to NgRx signals is because we aimed for it to be solution-agnostic. Therefore, these utility functions, `createSelectMap` and `createDispatchMap`, can be utilized in a similar manner with other state management solutions.

With that implemented and explained, we’re good to go.

Is this integration any useful?

Angular is making a big push towards signals. This means developers can expect signals to become increasingly important for building modern Angular applications. Next up, there are tons of external libraries that need to adapt to the new era of signals.

When it comes to signals in NGXS, it offers only selectSignal API. Don’t get me wrong, it’s totally enough, but there is no extra tooling for that at this point. 

In the context of NGXS, we’re generally referring to global stores, meaning data that is accessible across the entire application. Integrating a locally-scoped feature with the global store can be excessive, and there’s also a lack of API support for such integration. This is precisely what the @ngrx/component-store library is designed to assist with, and it appears that Signal Store is emerging as its natural successor.

We can delegate specific tasks to separate libraries – NgRx for managing local states, NGXS for managing the global state.

What about increased bundle size because of an extra library? Well, Signal Store is a relatively small library. It’s about 3.1kB minified, 1.2kB minified+gzipped. 

Downsides

There are natural risks of having 2 different state management libraries installed.  Among all, it increases the complexity of your application. It also increases the learning curve for new developers joining the project. Having different state management libraries with different sets of APIs and different ideas can be very confusing. Signal Store’s functional approach mixed with class-based NGXS might not fit too well for codebases with strict rules of writing code. Unit-testing will be more challenging because of an extra “layer” in the architecture.

Demo

We’re gonna use the todo app created by Fanis which was featured in one of our articles, and we will slightly refactor it. If you’re interested in gaining a deeper understanding of the app’s functionality, I strongly recommend reading the blog post.

Currently there are two ways of retrieving 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));
  }
}

We can also use a facade pattern to hide implementation details and aggregate selectors along with actions within one class. For that, we’ll create an injectable service that’s gonna be provided in 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 kind of solution allows us to share a streamlined, more maintainable, and easier-to-use interface for component interaction with the store, effectively encapsulating the complexity of business logic behind it. 

This works really well, but let’s refactor it a bit to see how it’s gonna fit for the Signal Store. Our store will look as the following:

const TodoStore = signalStore(
  withSelectors({
    todos: TodoSelectors.items,
  }),
  withActions({
    addTodo: AddTodo,
    changeStatus: ChangeStatus,
  })
);

@Component({
  providers: [TodoStore]
  // ...
}) 
class TodoComponent {
  readonly todoStore = inject(TodoStore);
}

What has changed? We don’t use explicit selectors from the NGXS store. Instead, we import our adapters to chain them within the signalStore function. Personally, I find this solution very clean and easy to read. It works in a similar manner to our previous facade implementation, but here we’re getting all the power that comes with the signal store. To prove that point we’re gonna utilize some signal store features for dispatching toast messages. We typically would prefer to keep it decoupled from the global store, so our TodoStore provided in the feature component fits perfectly for this purpose. 

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 above example we utilized the withComputed function to create a computed signal for tracking the count of todo items. Following this, the signal is employed in the onInit hook, where we listen for successful AddTodo actions in order to dispatch a toast message. 

Demo Playground:

https://stackblitz.com/edit/stackblitz-starters-tux39m

Summary

We proved that combining NGXS with Signal Store gives us new capabilities of managing the local state. Essentially, Signal Store creates an additional layer between NGXS’s global store and the UI. Its flexibility drives features to the next level. 

Even though using two different systems might seem complicated and a bit harder to learn at first, the advantage is clear. You get a powerful toolset that can handle any data management needs, from big to small. This matches well with where Angular is headed, offering a way to make apps that are not only easier to manage but also faster and more reliable.

References:

https://github.com/ngxs/store/blob/master/docs/concepts/select/signals.md

About the author

Dominik Donoch

Angular Developer at House of Angular. I’m an automation and IoT enthusiast. In my spare time, I enjoy hiking, traveling, and immersing myself in nature. ExpressionChangedAfterItHasBeenCheckedError enjoyer.

Don’t miss anything! Subscribe to our newsletter. Stay up-to-date with the latest trends, tips, meetups, courses and be a part of a thriving community. The job market appreciates community members.

Leave a Reply

Your email address will not be published. Required fields are marked *