State-Management in Frontend-Applikationen Eine Vorstellung von Akita und Akita Effects

Autor / Redakteur: Yannick Boetzkes * / Stephan Augsten

Akita ist eine vielgenutzte State-Management-Bibliothek für Angular-Applikationen. Durch die Erweiterung um Akita Effects in der kürzlich veröffentlichten Version 6 ist sie eine noch attraktivere Alternative zu NgRx oder NGXS geworden.

Firmen zum Thema

Die State-Management-Bibliothek Akita steht durch die Reduzierung von Boilerplate-Code und Tools wie das integrierte Effects-Modul für Simplizität.
Die State-Management-Bibliothek Akita steht durch die Reduzierung von Boilerplate-Code und Tools wie das integrierte Effects-Modul für Simplizität.
(Bild: maxxxiss / Pixabay )

Akita wird durch das Unternehmen Datorama und einer Vielzahl von Open-Source-Entwicklern gewartet und weiterentwickelt. Die Bibliothek ist lizenziert unter der Apache-2.0-Lizenz und wird durchschnittlich 35.000 mal pro Woche über npm heruntergeladen. Das Projekt ist mit aktuell knapp 3100 Sternen auf Github gelistet.

Was ist Akita?

Akita ist eine auf RxJS (Reactive Extensions Library for JavaScript) basierende State-Management-Bibliothek, die viele Ansätze der Redux und Flux Pattern aufgreift. Im Kern besteht sie aus einem Store Objekt, das in ein Observable eingebettet ist. Abonnierte Empfänger können somit über veröffentlichte Änderungen informiert werden.

Ablaufschema von Akita State Management.
Ablaufschema von Akita State Management.
(Bild: Datorama)

Über integrierte CRUD-Methoden besteht die Möglichkeit, das Store-Objekt zu manipulieren (immutable operations). Die Bibliothek basiert auf objektorientierten Design-Prinzipien und gibt eine strikte Architektur vor. Akita nutzt TypeScript und ist vollständig typisiert.

Der Datenfluss ist unidirektional aus dem Store zum jeweiligen Empfänger und der State kann nicht direkt über einen Objektzugriff manipuliert werden. Eine Veränderung des Status erfordert, dass das gesamte Store-Objekt durch ein neues, aktualisiertes Objekt ersetzt wird.

Komponenten haben über Akita Queries die Wahl, Daten in Form so genannter Observables oder eines lesenden Direktzugriffs auf eine Objekteigenschaft zu erhalten. Um den Store zu verändern, rufen Komponenten Servicemethoden auf, die wiederum den Store über von Akita zur Verfügung gestellte CRUD-Methoden verändern können.

Asynchrone Operationen wurden vor Version 6 ausschließlich in Services ausgelagert und explizit aufgerufen. Durch die Integration von Akita Effects besteht nun die Möglichkeit, asynchrone Operationen in Effekte auszulagern. Wie der Grafik zu entnehmen ist, besteht Akita aus drei Bausteinen: Store, Queries und Services, die nun um Actions und Effects ergänzt werden.

Akita Store

Akita Store stellt zwei verschiedene Arten von Stores zur Verfügung. Der Basisstore entspricht der Form eines nutzerdefinierten Interfaces und stellt CRUD-Methoden, wie beispielsweise add, set, update und delete, zur Verfügung. Ein typischer UI State kann beispielsweise wie folgt aussehen.

export interface UiState {
   isSnackbarOpen: boolean;
   isMenuOpen: boolean;
   contextToken: string;
   // ...
}

Eine weitere, besondere Form des Stores ist der Entity Store. Zusätzlich zu den Funktionen des Basisstores besteht die Möglichkeit Entitäten zu speichern und zu sortieren. Ein schneller und effizienter Zugriff auf Entitäten wird über eine HashMap gewährleistet. Die Sortierung wird in Form eines Id Arrays sichergestellt. Des Weiteren kann über den Store der aktuelle Ladezustand und die derzeit aktive Entität verfolgt werden.

export interface EntityState<T> {
   entities: HashMap<T>;
   ids: ID[];
   loading: boolean;
   error: any;
   active?: T | null;
}

Akita Queries

Über Queries können Empfänger (UI, Effects und Services) auf Daten aus dem Store zugreifen. Queries lassen sich als Observables oder als Direktzugriff auf Eigenschaften aus dem Store definieren. Komponenten sollten nicht direkt auf den Store zugreifen und stets Queries verwenden. So wird sichergestellt, dass Änderungen an Eigenschaften des States nicht in Komponenten vorgenommen werden können.

Aus dem Store können auch einzelne Eigenschaften selektiert werden, die nur bei dessen Änderung ein Veröffentlichungsevent zur Folge haben. Dadurch werden ungewollt ausgelöste Change-Detection-Zyklen in der UI verhindert. Queries sind wiederverwendbar und es besteht die Möglichkeit, sie mit anderen Queries zu kombinieren. Queries können die erhaltenen Daten aus dem Store vor der Weitergabe an einen Empfänger manipulieren und erweitern, um beispielsweise Daten für die View aufzubereiten (Instanziierung einer Klasse).

@Injectable({ providedIn: 'root' })
export class ProductQuery extends QueryEntity<ProductState> {
   constructor(protected store: ProductStore) {
      super(store);
   }
   isInitialized = this.getValue().initialized   selectEntity(id: Id): Observable<Product> {
      return this.selectEntity(id).pipe(
         map((entity) => entity && new Product(entity))
      );
   }
}

Akita Effects & Actions

Mit Akita Version 6 bietet die Bibliothek die Möglichkeit, Seiteneffekte über RxJS Observables mittels Effects zu behandeln.

Was sind Seiteneffekte im Redux-Kontext?

Seiteneffekte sind Operationen, die als Konsequenz aus einer aufgerufenen Aktion entstehen und meist das Ziel haben, den Anwendungszustand zu verändern. Sie können synchron oder asynchron verlaufen und beinhalten häufig Logik zur Datenbeschaffung. Ein simples Beispiel ist das Laden von Serverdaten: Eine Aktion beschreibt die Absicht Daten zu laden und ein Seiteneffekt lädt die Daten vom Server, um anschließend den State über eine weitere Aktion zu verändern.

Ein Effekt ist auf eine oder mehrere Aktionen des Action Streams abonniert. Sobald die Aktion versendet wird, werden alle abonnierten Seiteneffekte ausgelöst. Die Verwendung von Seiteneffekten erlaubt es explizite Servicemethodenaufrufe aus Komponenten heraus zu umgehen. Im Effekt besteht die Möglichkeit, per Dependency Injection auf weitere Services zuzugreifen, wodurch Operationen innerhalb des Effekts auf benötigte Daten von externen Quellen zugreifen können.

Seiteneffekte sind in vielen Anwendungen eine komplexe Problemstellung, da asynchrone Operationen den State zu unbestimmten Zeitpunkten verändern können. Effekte ermöglichen es asynchrone Operationen zu orchestrieren und entsprechende Handlungen nach Vollendigung vorzunehmen. Sie können sequenziell oder parallel ausgeführt werden.

Um Effekte nutzen zu können ist es notwendig Aktionen zu definieren. Effekte filtern einen Strom von Aktionen und werden ausgeführt, sobald die registrierten Aktionen emittiert werden. Aktionen sind Objekte, die einen Typ und optionale Nutzdaten enthalten.

// simplified
interface Action<T> {
   type: string;
   payload: T
}

Mithilfe einer Hilfsfunktion können Aktionen einfach erstellt und typisiert werden.

export const changeLanguage = createAction('Change Language', props<{ locale: Locale }>());

Parallelen zu bekannten Mustern aus NgRx sind bewusst integriert, um den Einstieg für Nutzer mit NgRx-Vorwissen zu erleichtern.

Effekte werden in einer Effekt-Klasse definiert. Wie auch die Query-Klasse ist die Effekt-Klasse ein Injectable.

@Injectable({
   providedIn: 'root'
})
export class UiEffects {
   constructor(
      private actions$: Actions,
      private uiService: UiService,
      private store: UiStore
   ) {
   }
   @Effect({ dispatch: true })
    changeLanguage$ = this.actions$.pipe(
      ofType(UiAction.changeLanguage),
      tap(({ locale }) => {
         this.store.set({ showLoadingOverlay: true });
         this.uiService.changeTranslations(locale);
      }),
      switchMap(({ locale }) => this.uiService.loadContextToken(locale)),
      map(res => NavigationAction.translateNavigation())
   );
}

Ein Effekt kann entweder mit einer @Effect Annotation versehen werden oder die Hilfsfunktion createEffect verwenden. Der Effekt referenziert den Action Stream, der automatisch abonniert wird. Die Quelle des Streams ist ein Actions Observable, in dem alle Aktionen emittiert werden.

Über den ofType Operator werden die Aktionen gefiltert, die den entsprechenden Seiteneffekt auslösen sollen. Im obigen Beispiel wird bei der emittierten changeLanguage Aktion eine Variable showLoadingOverlay im Store gesetzt und eine Methode im uiService aufgerufen. Der darauffolgende switchMap Operator erstellt ein neues, inneres Observable, das den Aufruf einer API auslöst.

Über die Konfiguration {dispatch: true} wird dem Effekt-Modul mitgeteilt, dass der Effekt eine weitere Aktion auslösen soll. In diesem Fall muss dem Action Stream über einen Rückgabewert eine Aktion übermittelt werden. Standardmäßig ist der Wert false. Mithilfe des map Operators wird der Rückgabewert manipuliert und eine Aktion wird zurückgegeben.

Aktionsaufruf

Die Komponente informiert die Applikation lediglich darüber, dass der Nutzer eine Aktion vornehmen möchte, beziehungsweise ein Event ausgelöst wurde. Die daraus entstehende Konsequenz wird im Effekt definiert.

this.actions$.dispatch(UiAction.changeLanguage({ locale: language }));

Wenn benötigt, kann auch der Effekt wiederum eine Aktion auslösen. Eine solche Verkettung von Effekten lässt sich dazu verwenden, einzelne Effekte wiederverwendbar zu machen. Ein Effekt mit gekapselter, zielgerichteter Logik kann auf mehrere Aktionen reagieren und wiederum eine weitere Aktion auslösen, die über die Vollendigung informiert. Es ist empfehlenswert, kleine, prägnante Effekte zu definieren. Überladene Effekte, die mehrere Operationen mit verschiedenen Zielen ausführen sind häufig limitiert in ihrer Wiederverwendbarkeit.

Ablaufschema von Akita State Management inklusive Akita Effects.
Ablaufschema von Akita State Management inklusive Akita Effects.
(Bild: Boetzkes / Datorama)

Im Gegensatz zu einem expliziten Aufruf einer Servicemethode aus einer Komponente heraus sind die Komponente und der Effekt vollständig voneinander entkoppelt. Es wird ausschließlich über veröffentlichte und abonnierte Events kommuniziert.

Registrierung

Jede Effekt-Klasse muss im AkitaNgEffectsModule registriert werden. Das Modul stellt die Methoden forRoot und forFeature zur Verfügung. Alle vorab geladenen Effektklassen sind wiederum in der forRoot-Methode, Lazy-loaded-Module in der forFeature-Methode zu registrieren.

Je mehr Effekte nachträglich über die forFeature-Methode, anstatt vorab über die forRoot-Methode geladen werden, desto kleiner ist das Main Bundle. Effekte, die zum Anwendungsstart unabhängig des geladenen Moduls benötigt werden, müssen in der forRoot-Methode registriert werden.

AkitaNgEffectsModule.forRoot([UiEffects])

Fazit

Yannick Boetzkes
Yannick Boetzkes
(Bild: adesso SE)

Akita 6 ist durch die Integration von Akita Effects eine noch attraktivere Alternative zu NgRx. Da die Bibliothek auf einige bekannte Muster aus NgRx zurückgreift, ist die Lernkurve für Entwickler mit Redux/NgRx-Vorwissen nicht allzu groß. Ein Vorteil von Akita ist, dass weniger Code benötigt wird, um ein strukturiertes State Management zu implementieren, da keine Reducer definiert werden müssen und einige Funktionalitäten durch generische Hilfsmethoden bereits integriert sind.

* Yannick Boetzkes ist Software Engineer bei adesso SE in Dortmund und Open Source Contributer. Er ist Full Stack Entwickler mit mehrjähriger Erfahrung im professionellen Umfeld. Seine Schwerpunkte liegen auf der Konzeption und Entwicklung von Web Anwendungen mit JavaScript-Technologien.

(ID:47442655)