Wróć do strony głównej
Angular

Signal Store & NGXS: Zwiększanie Elastyczności w Zarządzaniu Stanem

Od czasu wprowadzenia sygnałów w Angularze otworzyły się nowe możliwości budowy nowych API wokół tej technologii, w tym zarządzania stanem. Najlepsze rozwiązanie w tym zakresie zostało opracowane przez zespół NgRx, głównie przez Marko Stanimirovica, który wprowadził ich implementację zarządzania stanem opartego na sygnałach – bibliotekę @ngrx/signals. Rozwiązanie to zostało szeroko zaakceptowane przez społeczność, ponieważ jest proste, a jednocześnie bardzo elastyczne i efektywne. Może być używane do zarządzania stanem lokalnym i globalnym. Biblioteka oferuje pomocne narzędzia (takie jak signalStoreFeature), które pomagają nam skalować nasz store lub budować współdzielone elementy. To pozwoliło community na tworzenie własnych rozszerzeń, takich jak @angular-architects/ngrx-toolkit z wieloma pomocnymi wtyczkami.

Signal Store i jego elastyczność

Mając to na uwadze, możemy pomyśleć, że Signal Store jest w pewnym sensie kompletną biblioteką zarządzania stanem. Ze względu na swoją naturę możemy dostosować store’a do naszych potrzeb. Obejmuje to również tworzenie adapterów do wewnętrznych store’ów projektowych lub zewnętrznych rozwiązań. Jednym z nich jest NGXS, którego autorzy zdecydowali się nie tworzyć własnej implementacji sygbałowego store’a, ale stworzyć zestaw narzędzi, które pozwalają na połączenie @ngxs/store z Signal Store od NgRx. Aplikacje oparte na storze NGXS mogą teraz zintegrować NgRx Signal Store ze swoim kodem w bardzo prosty sposób. Zespół NGXS dostarczył oficjalny przewodnik pokazujący, jak skonfigurować tę integrację, przez który dokładnie przejdziemy.

Wstępna konfiguracja

UWAGA: W momencie publikacji wszystkie opisane funkcje nie zostały jeszcze wydane, więc API może się zmienić! Jeśli chcesz się nimi pobawić, upewnij się, że używasz wersji deweloperskiej (użyj npm view @ngxs/store versions).

Zakładając, że już mamy zainstalowany @ngxs/store, musimy tylko dodać Signal Store do naszych zależności.

npm install @ngrx/signals

Musimy zintegrować mały, wielokrotnie używalny fragment kodu do naszej bazy kodów, aby stworzyć most między NGXS a Signal Store, który umożliwia komunikację między tymi dwiema bibliotekami.

Aby tego dokonać, musimy użyć dwóch funkcji pomocniczych, które są udostępniane przez NGXS:

  • createSelectMap – przyjmuje obiekt wejściowy z selektorami jako wartościami, aby wytworzyć z nich sygnały
  • createDispatchMap – opakowuje akcje w wywoływalne funkcje

Te funkcje pozwalają nam stworzyć niestandardowe adaptery, które łączą globalny store NGXS z Signal Store. Najlepszą częścią jest to, że są one zawarte w głównej bibliotece, więc nie ma potrzeby instalowania żadnych dodatkowych sub-paczek, aby ich używać. Użyjmy tych funkcji, aby stworzyć naszą integrację w pliku, który jest łatwo dostępny, czyniąc go reużywalnym dla całej aplikacji.

Pierwszy adapter to withSelectors. Ten adapter tworzy computed sygnały z dostarczonych selektorów. Wykorzystuje funkcję withComputed, aby opakować rezultat createSelectMap.

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

Możemy go użyć w następujący sposób:

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

Drugi adapter to withActions, który tworzy metody do wywoływania dostarczonych akcji. Używa funkcji withMethods, która pobiera mapę dispatcherów z createDispatchMap i konwertuje ją na metody w Signal Store.

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

Teraz możemy dodać go do naszego poprzedniego przykładu kodu:

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

Nasze adaptery są właściwie Signal Store feature’ami, zaprojektowanymi tak aby zapewnić kompatybilność z nim a NGXS. Wykorzystują funkcję signalStoreFeature, która jest głównym motorem systemu rozszerzalności Signal Store, umożliwiając naszą integrację.

Jak już zauważyłeś, musimy stworzyć te funkcje ręcznie. Nie są one dostarczane „od ręki” w NGXS. Po prostu pozwala to pakietom NGXS nie polegać na zależnościach NgRx. Możemy znaleźć oficjalne uzasadnienie w ich dokumentacji:

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.

Mając wyjaśnioną główne założenia, możemy przejść dalej.

Czy ta integracja jest w ogóle przydatna?

Angular mocno stawia na sygnały. Oznacza to, że deweloperzy mogą się spodziewać, że sygnały staną się coraz ważniejsze przy budowaniu nowoczesnych Angularowych aplikacji. Mamy mnóstwo zewnętrznych bibliotek, które będą powoli dostosować się do nowego nurtu opartego na sygnałach.

Jeśli chodzi o sygnały w NGXS, oferuje on w swoim API tylko funkcję selectSignal. Nie zrozum mnie źle, jest to całkowicie wystarczalne, ale na tym etapie nie ma żadnego dodatkowego wsparcia.

W kontekście NGXS zazwyczaj mówimy o globalnych store’ach, czyli zbiorach danych, które są dostępne w całej aplikacji. Integracja lokalnych funkcjonalności z globalnym store’em może być po prostu trudna, a brak dedykowanego wsparcia od strony API powoduje dodatkowe problemy. Właśnie do tego służy biblioteka @ngrx/component-store, a wydaje się, że Signal Store jest jej naturalnym następcą.

Możemy delegować konkretne zadania do oddzielnych bibliotek – NgRx do zarządzania stanami lokalnymi, NGXS do zarządzania stanem globalnym.

Co z powiększonym rozmiarem pakietu z powodu dodatkowej biblioteki? Cóż, Signal Store to stosunkowo mała biblioteka. Ma około 3,1kB minified

Wady

Istnieją naturalne ryzyka posiadania zainstalowanych dwóch różnych bibliotek do zarządzania stanem. Pośród wszystkich zwiększa to złożoność aplikacji. Zwiększa również krzywą uczenia się dla nowych deweloperów dołączających do projektu. Posiadanie różnych bibliotek zarządzania stanem z różnymi zestawami API i różnymi ideami może być bardzo mylące. Funkcjonalne podejście Signal Store mieszane z klasowym NGXS może nie pasować zbyt dobrze do baz kodów z surowymi zasadami pisania kodu. Testowanie jednostkowe będzie bardziej wymagające z powodu dodatkowej „warstwy” w architekturze.

Demo

Zamierzamy użyć aplikacji todo stworzonej przez Fanisa, która była przedstawiona w jednym z naszych artykułów, i będziemy ją nieco refactorować w trakcie. Jeśli chciałbyś dowiedzieć się więcej na temat funkcjonalności w przedstawianej aplikacji, zdecydowanie polecam przeczytać post na blogu.

Obecnie istnieją dwa sposoby pobierania danych ze stanu NGXS:

  • metoda select – zwraca wartość w postaci observable
  • metoda selectSignal method – zwraca wartość w sygnale
@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));
  }
}

Możemy także użyć wzorca fasady, aby ukryć szczegóły implementacji i agregować selektory wraz z akcjami w jednej klasie. W tym celu stworzymy injectowalny serwis, który zostanie zprovidowany w komponencie.

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

To rozwiązanie pozwala nam udostępnić bardziej uporządkowany, łatwiejszy w utrzymaniu i prostszy w użyciu interfejs do interakcji komponentów ze storem, skutecznie ukrywając za nim złożoność logiki biznesowej.

Na ten moment, działa to bardzo dobrze, ale przeprojektujmy to trochę, aby zobaczyć, jak nasze rozwiązanie będzie wyglądać z użyciem Signal Store. Nasz store będzie wyglądał następująco:

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

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

Co się zmieniło? Nie używamy już jawnie selektorów z NGXS. Zamiast tego importujemy nasze adaptery, aby połączyć je w signalStore. Osobiście uważam, że to rozwiązanie jest bardzo czytelne i łatwe do odczytania. Działa w sposób podobny do naszej poprzedniej implementacji fasady, ale tutaj otrzymujemy cały wachlarz możliwości, które niesie ze sobą Signal Store. 

Aby to udowodnić, będziemy wykorzystywać niektóre funkcje Signal Store’a do wysyłania toast’ów. Zazwyczaj wolelibyśmy, aby było to odłączone od globalnego store’a, więc nasz TodoStore dostarczany na poziomie komponentu doskonale się do tego nadaje.

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

W powyższym przykładzie wykorzystaliśmy funkcję withComputed, aby stworzyć sygnał śledzący liczbę elementów todo. Następnie sygnał ten jest wykorzystywany w hooku onInit, gdzie nasłuchujemy na zakończenie akcji AddTodo, aby następnie wysłać do użytkownika komunikat toast.

Demo Playground:

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

Podsumowanie

Udowodniliśmy, że połączenie NGXS z Signal Store daje nam nowe możliwości zarządzania stanem lokalnym. Można powiedzieć, że Signal Store tworzy dodatkową warstwę między globalnym storem NGXS a interfejsem użytkownika. Jego elastyczność pomaga tworzyć o wiele lepsze rozwiązania.

Chociaż korzystanie z dwóch różnych systemów może wydawać się skomplikowane i trochę trudniejsze do nauczenia, korzyści są jasno widoczne. Otrzymujesz potężny zestaw narzędzi, który może sobie poradzić z każdą potrzebą zarządzania danymi, od dużych po małe. Idealnie wpasowuje się kierunek, w którym zmierza Angular, oferując sposób na tworzenie aplikacji, które nie tylko są łatwiejsze w zarządzaniu, ale także szybsze i bardziej niezawodne.

Źródła:

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

O autorze

Dominik Donoch

Angular Developer w House of Angular. Fan IoT i automatyzacji wszelkiej formy. W wolnych chwilach podróżuję i podziwiam naturę. ExpressionChangedAfterItHasBeenCheckedError enjoyer.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *