Zurück zu Blogs
Web Entwicklung

NgRx Signal Store – worth the hype?

Der NgRx Signal Store gilt als richtungsweisend in der neuen Angular Welt und soll eine echte Alternative zum klassischen NgRx Store darstellen. In diesem Artikel ziehen wir Fazit nach einem halben Jahr Entwicklung.

Daniel Kappacher

Daniel Kappacher

NgRx Signal Store – worth the hype?

Spätestens seit dem Release von Version 17 ist klar, dass das Angular Team eine neue Richtung einschlagen will. Im Mittelpunkt davon: Angular Signals. Diese sollen den Einstieg in die Angular Welt erleichtern, die Change Detection optimieren und das kontroverse RxJS zu einem optionalen, allerdings immer noch mächtigen, Werkzeug anstatt integralen Bestandteil des Frameworks machen.

Das NgRx Team hat zeitgleich mit dem Angular 17 Release die erste Version der @ngrx/signals Library veröffentlich und bietet damit direkt eine flexible und einfache Möglichkeit zum signal-based State Management an. Der darin enthaltene lightweight Signal State und etwas umfangreichere Signal Store sind dabei komplett abgekapselt zum bereits etablierten RxJS-based NgRx Store und Component Store zu betrachten.

Doch inwieweit unterscheidet sich der Signal Store von bisherigen State Management Lösungen, welche Features kommen out-of-the-box mit und ist eine zusätzliche externe Library überhaupt notwendig für das State Management von Signals? Wir haben den NgRx Signal Store bereits seit einem halben Jahr im Einsatz und ziehen Fazit.

Signal State

Wie bereits erwähnt gibt es zwei verschiedene Ausprägungen der NgRx Signals Library. Der Signal State ist eine lightweight und minimalistische Utility zum Verwalten von signal-based State. Dieser eignet sich beispielsweise besonders gut für abgekapselte States auf Komponenten Ebene. Dazu wird einfach die signalState Funktion aus dem Package eingebunden und mit dem initialen State initialisiert.

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

const userState =
  signalState <
  UserState >
  {
    firstName: 'John',
    lastName: 'Doe',
    address: {
      street: 'Street 1',
      zip: 1234,
    },
  };

Sieht auf den ersten Blick nicht viel anders aus als ein gewöhnliches Angular Signal, unterscheidet sich aber folgendermaßen:

  • Deep Signal: Der Signal State erzeugt im Hintergrund nicht nur für das userState Objekt ein readonly Signal, sondern zusätzlich auch für jedes verschachtelte Property. Somit kann beispielsweise die address Property mit userState().address und userState.address() ausgelesen werden und erlaubt für “fine-grained reactivity”. Konsumenten des Signals können den State also auf Property Ebene auslesen und werden bei anderen Änderungen des States nicht benachrichtigt und unnötige Change Detection Cycles bleiben erspart.
  • Immutable State Updates: Um unerwartete Side Effects zu verhindern, bietet die patchState Funktion die Möglichkeit an den State auf eine kontrollierte Art und Weise zu aktualisieren.
import { patchState } from '@ngrx/signals';

patchState(userState, { firstName: 'Jane' });
patchState(userState, (state) => ({ address: { ...state.address, zip: 5678 } }));

Zusätzlich dazu können auch wiederverwendbare State Updaters erstellt werden.

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

function setZip(zip: number): PartialStateUpdater<{ address: Address }> {
  return (state) => ({ address: { ...state.address, zip } });
}

patchState(userState, setZip(5678));

Dieser funktionelle Ansatz zum State Management ermöglicht hohe Flexibilität und deckt wahrscheinlich die Anforderungen der meisten Use Cases ab.

Signal Store

Mit dem Signal Store hat NgRx die Basis Funktionalität des Signal States um einige Features erweitert und ist in der Lage komplexeren Anforderungen gerecht zu werden. Der Store wird mit der signalStore Funktion erstellt und liefert ein Injectable Service zurück. Der generierte Service kann entweder in root provided werden und als Singleton einen applikationsweiten State verwalten oder direkt in Komponenten provided werden und als Component Store eingesetzt werden. Der Signal Store umfasst folgende Konzepte:

  • withState: Analog zum signalState kann mit der withState Funktion der initiale State des Stores gesetzt werden.
  • withComputed: Basierend auf den initial erstellten Signals können direkt im Store berechnete Werte (Computed Values) definiert werden. Damit kann featurespezifische Logik von der Komponente in den Store ausgelagert werden und hilft dabei die Komponenten schlank zu halten.
  • withMethods: Um zu vermeiden, dass der State von überall in der Applikation aktualisiert werden kann, werden mit der withMethods Funktion die State Update Logik bereit gestellt und führt mögliche asynchrone Side Effects entweder mit Promises aus oder wie bisher mit RxJS mithilfe der rxMethod Funktion.
  • rxMethod: Wer nicht komplett auf die Stärken der RxJS Operators verzichten will, kann ganz einfach mit rxMethod Funktionalität ausgehend von einem Observable Stream ausführen. Entweder durch manuelle Trigger oder als Reaktion auf einen Signal Change emitted das Source Observable einen neuen Wert und macht es so möglich komplexere Side Effects zu handhaben.
  • Lifecycle Hooks: Die mitgelieferten onInit und onDestroy Hooks machen den Store noch unabhängiger und in sich konsistent. Man sieht beispielsweise auf einen Blick, wie sich der initiale State zusammensetzt, ohne die gesamte Applikation für Methodenaufrufe durchsuchen zu müssen.
  • Custom Features: Das auf den ersten Blick vielleicht unscheinbarste Feature ist womöglich das Mächtigste. Der Signal Store ermöglicht es wiederkehrende State Logik in eigene Features zu kapseln, die je nach Bedarf in den Store eingebunden werden. So kann man beispielsweise eine Loading/Error State Logik (inklusive State Properties, Computed Properties, Methods, etc.) auslagern und jeder Store der dieses Features aktiviert kann mit nur einer Code Zeile auf diese Logik aufbauen. Die perfekte Ausgangssituation für eine flexible Softwarearchitektur.

Ein Beispiel für einen simplen Signal Store könnte folgendermaßen aussehen.

import { patchState, signalStore, withComputed, withMethods, withState} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { withLoadingState } from '../loading-state-feature';
[...]

const initialState: UsersState = {
  users: [],
  filter: ''
};

@Injectable({ providedIn: 'root' })
export class UserStore extends signalStore(
  withState(initialState),
  withLoadingState(), // <- Activate Custom Feature to access isLoading property
  withComputed(({ users, filter }) => ({
    usersCount: computed(() => users().length),
  })),
  withMethods((store, usersService = inject(UsersService)) => ({
    updateFilter(filter: string): void {
      patchState(store, { filter });
    },
    connectFilter: rxMethod<string>(
      pipe(
        debounceTime(300),
        distinctUntilChanged(),
        tap(() => patchState(store, { isLoading: true })),
        switchMap((query) => {
          return usersService.getByFilter(filter).pipe(
            tapResponse({
              next: (users) => patchState(store, { users }),
              finalize: () => patchState(store, { isLoading: false }),
            })
          );
        })
      )
    ),
  })),
  withHooks({
    onInit(store) {
      store.connectFilter(store.filter);
    },
  })
);

Fazit

Durch den funktionellen Ansatz und der flexiblen Anwendung bringt der Einsatz der Library viele Vorteile und wenige bis keine Nachteile mit. Das State Management verleiht der Applikation ausreichend Struktur und eine einheitliche Architektur bei geringem Overhead und gleichzeitig minimaler Vergrößerung der Bundle Size.

Unsere wichtigsten Erkenntnisse:

  • Flexibilität: Je nach Feature oder Komponente kann der Store als Component Store oder globaler App Store eingesetzt werden. Sollte unklar sein, an welcher Stelle der App Struktur der Store erstellt und eingebunden wird, gilt als Faustregel den Store so spezifisch wie möglich zu integrieren. Sollte es notwendig sein, den Store globaler zu machen, ist das mit wenig Aufwand möglich. Somit können unnötige Abhängigkeiten einfach vermieden werden.
  • Schlanke Komponenten: Mit der withComputed Methode lässt sich Komponentenlogik einfach und an einer Stelle im Store definieren und sorgt dafür, dass die Komponenten möglichst klein und wartbar bleiben. Der Einsatz von Computed State Properties versichert stabile und berechenbare Change Detection Cycles.
  • Custom Features: Generische und wiederkehrende Logik lässt sich unkompliziert in eigene Files auslagern und fördert eine flexible Architektur.

Gerade in Projekten mit begrenztem vorhandenen RxJS Know How erleichtert der Signal Store die initiale Implementierung des State Managements und das Onboarding neuer Entwickler. Die Architektur des klassischen NgRx State Management mit strikter Trennung von Actions, Effects, Reducers und Selectors hat natürlich Vorteile in größeren Projekten, ist die etabliertere Lösung und ist von vielen Teams möglicherweise noch die präferierte Option. Dennoch sehe ich die den Signal State/Store als die flexiblere und einfachere State Management Lösung an, vor allem aufgrund des Shifts in Richtung Signals im Angular Framework.

Share this post
Daniel Kappacher

Daniel Kappacher