Will Angular Signals kill state management libraries like NgRx and NGXS?

Xavier Dupessey
Criteo Tech Blog
Published in
4 min readFeb 27, 2024
Photo from Ben Wicks on Unsplash

Although this mechanism is increasingly used to share data between components, Angular still has not integrated a state management system. Libraries such as NgRx and NGXS offer Flux implementations to address this need.

However, in version 16, Angular introduced the notion of Signals:

A signal is a wrapper around a value that can notify interested consumers when that value changes. Signals can contain any value, from simple primitives to complex data structures.

— https://angular.io/guide/signals

In the examples from the official documentation, Signals are declared and used within the same component. But nothing prevents them from being used in a Service, making it a more global state! Let’s take a look.

Consider a fictive application displaying an array of cars and their drivers:

We have two states, one for each kind of entity, Drivers and Cars.
A Driver comprises an id and a name, and a Car has an additional driverId attribute. It is possible to select a car, highlighting its row.

// drivers/drivers.store.ts

import { Injectable } from '@angular/core';
import { Store } from '../ngxss-lib/store';

export interface DriversStoreModel {
drivers: Driver[];
}

export interface Driver {
id: number;
name: string;
}

@Injectable()
export class DriversStore extends Store<DriversStoreModel> {
override initialModel = {
drivers: [
{ id: 0, name: 'Artem' },
{ id: 1, name: 'Elena' },
{ id: 2, name: 'Eric' },
{ id: 3, name: 'Kevin' },
{ id: 4, name: 'Sam' },
{ id: 5, name: 'Seif' },
{ id: 6, name: 'Sylvain' },
],
};
}
// cars/cars.store.ts

import { Injectable, inject } from '@angular/core';
import { Store } from '../ngxss-lib/store';

export interface CarsStoreModel {
cars: Car[];
selectedCarId: number;
}

export interface Car {
id: number;
name: string;
driverId: number;
}

@Injectable()
export class CarsStore extends Store<CarsStoreModel> {
override initialModel = {
cars: [
{ id: 0, name: 'Bugatti Chiron', driverId: 0 },
{ id: 1, name: 'Porsche 911', driverId: 1 },
{ id: 2, name: 'Ford Mustang', driverId: 2 },
],
selectedCarId: 2,
};
}

The base class Store is defined in the ngxss-lib directory. Its main purpose is to wrap the initialModel property in a Signal and expose a few utility functions.

this.dataRW = signal(this.initialModel);
this.data = this.dataRW.asReadonly();

It’s now very easy to list cars and their drivers:

// cars/cars.component.ts

@Component({
// [...]
providers: [CarsStore, CarsSelectors], // Scope a state to a component!
selector: 'cars',
template: `
Cars list:

<table>
<tr>
<th>Id</th>
<th>Name</th>
<th>Driver</th>
<th></th>
</tr>
<tr *ngFor="let car of carsSelectors.cars()">
<th>{{ car.id }}</th>
<th>{{ car.name }}</th>
<th>{{ car.driverName }}</th>
</tr>
</table>
`,
})
export class CarsComponent {
readonly carsStore = ngxssInject(CarsStore);
readonly carsSelectors = ngxssInject(CarsSelectors);
}

If you remember the Car model, driverName is not a direct property of this object; it's calculated dynamically by another selector:

// cars/cars.selectors.ts

export interface CarExtended extends Car {
driverName: string | undefined;
}

@Injectable()
export class CarsSelectors extends Selector<CarsStoreModel> {
private readonly driversSelectors = inject(DriversSelectors); // DI can be used in Selectors!

public override store = CarsStore;

cars: Signal<CarExtended[]> = computed(() => {
console.log('CarsSelectors.cars');
return this.slices.cars().map((car) => {
const driverName = this.driversSelectors.driverName(car.driverId)();
return { ...car, driverName };
});
});
}
// drivers/drivers.selectors.ts

@Injectable()
export class DriversSelectors extends Selector<DriversStoreModel> {
public override store = DriversStore;

driverName(id: number) {
return computed(() => {
console.log('DriversSelectors.driverName id=', id);
// In a real application, a Map should be used instead of the array drivers() for better complexity: O(1) VS O(n)
return this.slices.drivers().find((driver) => driver.id === id)?.name;
});
}
}

This shows how selectors can be composed thanks to the native Signals capabilities.

Of course, it’s also possible to define actions on the store to modify its data, for example, to delete a car:

// cars/cars.store.ts

@Injectable()
export class CarsStore extends Store<CarsStoreModel> {
private readonly carSelectors = inject(CarsSelectors); // Allow using selectors in the store!

override initialModel = [...];

deleteCar(carId: number) {
console.log('CarsStore.deleteCar carId=', carId);
const cars = this.carSelectors.slices.cars();
this.patch({ cars: [...cars.filter((car) => car.id !== carId)] });
}
}

This deleteCar action can be called directly from a component, cf line 16:

// cars/cars.component.ts

@Component({
// [...]
selector: 'cars',
template: `
Cars list:

<table>
<!-- [...] -->
<tr *ngFor="let car of carsSelectors.cars()">
<th>{{ car.id }}</th>
<th>{{ car.name }}</th>
<th>{{ car.driverName }}</th>
<th>
<button (click)="carsStore.deleteCar(car.id)">Delete</button>
</th>
</tr>
</table>
`,
})
export class CarsComponent {
readonly carsStore = ngxssInject(CarsStore);
readonly carsSelectors = ngxssInject(CarsSelectors);
}

Finally, our stores and selectors are simple Angular services that can be scoped to a component! This means we can use the component cars twice, each with their own state, cf lines 10 and 11!

// main.ts

@Component({
// [...]
providers: [DriversStore, DriversSelectors],
selector: 'app-root',
template: `
Components use their own state:<br /><br />

<div><cars></cars></div>
<div><cars></cars></div>
`,
})
export class AppComponent {
private readonly driversStore = ngxssInject(DriversStore);
private readonly driversSelectors = ngxssInject(DriversSelectors);
}

bootstrapApplication(AppComponent);

Here, only the drivers state is common. However, the cars state is instantiated for each cars component.

Of course, this POC is very basic and lacks many features offered by NGXS:

  • Send actions with an event system
  • Subscribe to sent / successful / failed actions
  • Abort an asynchronous action if it is resent before completion

However, with just a few lines of code, we also have benefits that are not available with NGXS:

  • Scope a state to a specific component
  • Use selectors in the store (from two different files) without circular dependencies issues
  • Inject services into selectors

Signals are thus very promising and could finally shake up the existing state management mechanisms for Angular applications. The advantages this new Angular API offers will enable talented developers to develop innovative libraries, promising an exciting future!

The full POC codebase is available on StackBlitz 👀

--

--