Einen Einstieg in Angular zu finden ist seit jeher sehr einfach, auch wenn jemand über wenig bis keine Erfahrung mit der Entwicklung von Web-Applikationen hat. Das liegt neben der stetig steigenden Anzahl an Kursen, Anleitungen und beantworteten Fragen im Netz, auch an der „Tour of Heroes“ – dem Einsteigerprojekt von Angular selbst.
Mit ihr habe ich vor drei Jahren den Einstieg in die Thematik gefunden und auch heute lege ich sie gerne jedem Interessierten ans Herz. Sie vermittelt einen guten Eindruck zentraler Bestandteile von Angular sowie deren Syntax. Die Inhalte reichen für erste eigene Projekte, doch es gibt einige wichtige Aspekte einer Web-App, die auf der Strecke bleiben – natürlich auch, um Neulinge nicht zu überfordern oder gar abzuschrecken.
Meiner Meinung und bisherigen Erfahrung in Kundenprojekten nach fällt das State- Management eben in diese Kategorie. Daher möchte ich im Folgenden darauf eingehen, welche Probleme es bei der Entwicklung mit Angular gibt, die sich mit gutem State- Management lösen lassen, und mit dem Akita-Store eine einfach zu bedienende Lösung zeigen, um gerade Einsteigern einen Zugang zu der Thematik zu bieten.
Was ist der State?
Grundsätzlich arbeiten wir selbstorganisiert und haben keine festen Arbeitszeiten. (By the way: Diesen Blog-Beitrag habe ich zum Beispiel in den Abendstunden geschrieben, da es mir zu dieser Zeit einfach leichter fällt.) Von montags bis donnerstags steht um 08:30 Uhr unser Morning Coffee auf der Agenda – dieser ist freiwillig. Hier tauschen wir uns über den Status Quo unserer Projekte aus und teilen spannende Themen miteinander. Freitags haben wir unseren gemeinsamen Wochenrückblick, auch Fragen oder organisatorische Angelegenheiten können hier gut abgeklärt werden. Da sich unser Team deutschlandweit verteilt und auch insgesamt die Möglichkeit besteht, Remote-Work zu betreiben, findet unsere Kommunikation maßgeblich via MS-Teams statt. Durch unsere diversen Kanäle wird dies enorm erleichtert und jedes Anliegen kann schnellstmöglich geklärt werden. Je nach Arbeitsbereich finden individuell noch weitere Meetings statt.
Die Remote-Situation sehen wir als Fluch und Segen zugleich. Warum? Natürlich sind die Flexibilität und Anpassung für jedes Individuum praktisch für den Alltag und führen zu mehr Work-Life-Balance. Dennoch besteht die Herausforderung darin, die sozialen Beziehungen aufrechtzuerhalten und uns mit unseren Kollegen und Kolleginnen verbunden zu fühlen. Du kennst das vielleicht auch: Es existiert weniger informelle Kommunikation, da der übliche Austausch an der Kaffeemaschine oder bei der Begegnung im Flur ausfällt. Mimik und Körpersprache sind eingeschränkt und wir verbringen insgesamt weniger Zeit miteinander.
Wie gehen wir nun damit um? Wir haben noch weitere Events eingeführt, die bei Bedarf besucht werden können. Dazu gehört die Kaffeepause und der Wochenausklang. Auch dies sind keine Pflichtveranstaltungen, sondern sollen lediglich die Möglichkeit zu einem digitalen Kaffee und (privaten) Austausch geben. Ob das wirklich die Lösung ist? Das wissen wir derzeit nicht. Wir sind offen für Experimente und jedes Teammitglied darf dazu beitragen, ein für uns passendes Bedürfnisbefriedigungs-Tool zu finden. Hierfür treffen wir uns derzeit ca. alle zwei Wochen zu einem gemeinsamen Austausch und erarbeiten uns neue Lösungsmöglichkeiten, die wir dann beim nächsten Mal bewerten und bei Bedarf erneut anpassen. Hier merkst du ganz schnell: Joscha legt viel Wert auf Mitbestimmung und Mitarbeit im Unternehmen.
Wenn wir uns im Büro befinden, machen wir gegen 12:00 Uhr Mittag. Meistens wird etwas bei einem Lieferservice bestellt. Manchmal darf es aber auch ein Salat oder das Mittagessen vom Vortag sein. Auch für den kleinen Hunger gibt es immer einen kleinen Snack vor Ort. Nicht zu vergessen unsere 4+1-Zeit. Bei den meisten von uns hat sich hierfür der Freitag eingebürgert. Uns stehen hierfür 20 % der Arbeitszeit zur Verfügung. Die Zeit können wir für fachliche sowie methodische Fortbildungen nutzen, neue Themen und Technologien erkunden oder beispielsweise Workshops oder Konferenzen besuchen. Alles, was thematisch mit 19bytes zusammenhängt, ist willkommen.
Unsere quartalsweisen Team-Events gehören zwar nicht zu unserem Arbeitsalltag, aber ich würde sagen, dass diese zum ganz normalen Wahnsinn dazugehören. Sei es der Besuch im Escape Room, die Schnitzeljagd in Dortmund, Glühweintrinken auf dem Weihnachtsmarkt oder das Sommerfest. Denn auch der face-to-face Austausch ist uns sehr wichtig. Vor kurzem war das 19bytes-Team erst in Kroatien auf einer Workation. Auf den Blog-Beitrag darfst du gerne schon gespannt sein. Für Ende September wird gerade von zwei Teammitgliedern eine Online-Gaming-Session geplant.
Wenn wir bei einer Applikation vom „State“ reden, meinen wir dabei den aktuellen Stand der Daten. Das umfasst natürlich eine ganze Vielzahl von unterschiedlichen Informationen: Produktiv-Daten aus einer Datenbank, in einem Formular vorgenommene Änderungen, generelle User-Einstellungen (z.B. ob ein Dark-Theme verwendet wird) oder Informationen zum aktuellen Benutzer. Im Rahmen von Angular könnte man vereinfacht sagen: Der State ist die Zusammenfassung der Werte aller Variablen und Parameter in der aufgerufenen Applikation.
In Angular macht es dabei Sinn zwei Arten von States zu unterscheiden: den UI-State und den Domain-State. Der UI-State beinhaltet alle Informationen, die nur für Angular interessant sind, wie zum Beispiel Informationen, welche Checkbox gerade ausgewählt oder welches Farbschema aktiv ist. Der Domain-State umfasst alle Daten, die über ein beliebiges Backend mit einem Server synchronisiert werden.
Warum ist das Thema so wichtig?
In der „guten, alten Zeit“ wurde der Domain-State meist vom Backend verwaltet, sodass eine Web-Applikation oft keine Gedanken an die Korrektheit der Daten verschwenden musste. Änderungen wurden oft sofort persistiert, was dazu führte, dass diese an anderen Stellen ohne Verzögerung verwertet werden konnten.
Angular-Applikationen laufen allerdings losgelöst vom Backend und werden in der Regel über REST-Schnittstellen angebunden. Gleichzeitig wollen wir unsere Web-App möglichst in viele kleine abgeschlossene Komponenten aufteilen, die eventuell jeweils einen kleinen Teil des States verändern können sollen. Insgesamt muss also sichergestellt werden, dass
Änderungen über Komponenten hinweg kommuniziert werden und nicht bei jeder Anpassung alle Daten neu geladen werden müssen, um den Traffic zu reduzieren.
Der klassische Weg (was bisher bekannt ist)
Nehmen wir als Beispiel eine einfache Einkaufs-Applikation. Den Quell-Code dazu kannst du unter diesem Link finden. Die App holt eine Liste von verfügbaren Gütern von einem (gemockten) Backend und zeigt diese an. Der User kann sich Gegenstände zu seinem Einkaufswagen hinzufügen und anschließend über einen Checkout die Bestellung abschließen. Gehen wir dabei davon aus, dass das Backend nur CRUD-Operationen (Create, Read, Update und Delete) für Waren und eine Möglichkeit Bestellungen zu tätigen zur Verfügung stellt. Einen Einkaufswagen gibt es also nicht.
Wir haben dementsprechend einen Domain-State, in dem alle Waren vom Backend liegen, und einen UI-State, in dem der Einkaufswagen verwaltet wird (er kann ja schließlich nicht mit dem Backend synchronisiert werden). Der Begriff UI-State passt hier zwar nicht 100%, da wir durchaus Applikations-Logik abbilden und keine reinen Frontend-Werte verwalten, aber ich denke, die Idee wird klar. Mit den Methodiken aus der Tour of Heroes: Wie würde man die nötige Logik umsetzen?
Zur Interaktion mit beiden States würden wir je einen Service schreiben. Der Waren-Service würde die Liste der Waren als Array vom Backend laden und diese so zugreifbar machen.
getItems(): Observable {
return this.http.get(this.itemsUrl)
}
In einer Warenliste-Komponente kann man nun diese Methode aufrufen, das Ergebnis an das Template weiterreichen und in diesem über „ngFor“ für jede einzelne Ware eine Schaltfläche rendern, über die man sie zum Warenkorb hinzufügen kann.
Die einfachste Ausführung eines Einkaufswagens wäre ein Service, der eine Liste mit Waren bereitstellt, in die über Methoden Waren eingefügt und entfernt werden können. Der Service kann dann an beliebigen Stellen verwendet werden, um den Einkaufswagen auszulesen oder zu verändern.
Okay, das ist jetzt nicht unbedingt hübsch, aber mit ein wenig Politur würde das die Anforderungen doch erfüllen, oder? In diesem einfachen Fall: ja. Das Problem ist nur, dass die meisten Projekte nicht ganz so simpel aufgebaut sind. Nehmen wir mal an, wir würden die Waren in der Liste auch bearbeiten können. Wenn ich in einer Schaltfläche den Preis ändere, wie sorge ich dafür, dass dieser sowohl in der Liste als auch bei allen entsprechenden Waren im Warenkorb geändert wird? Wenn ich eine Bearbeitung erlaube: Wie sorge ich dafür, dass diese in der Applikation nur an anderen Stellen übernommen wird, sobald ich die Änderung erfolgreich ans Backend übermittelt habe? Wie vermeide ich bei Änderungen versehentliche Seiteneffekte, weil ich an manchen Stellen Referenzen weitergereicht habe?
Diese Aufgaben ließen sich sicherlich auch händisch lösen, aber je größer die Applikation wird, desto größer würden dabei meine Komponenten, um die nötigen Events zu werfen und zu verarbeiten. Doch zum Glück müssen wir das Rad nicht neu erfinden, sondern können auf eine Reihe von Bibliotheken zurückgreifen.
Wie geht es besser?
Eine dieser Bibliotheken ist der Akita Store, bzw. in unserem Fall eher der entsprechende Entity-Store. Die Idee hinter dem Store ist, dass wir unseren State in ihm ablegen und in unserem Service dafür sorgen, dass jedes Update (auch) im Store übernommen wird. Gleichzeitig nutzen wir den Store über Queries als zentrale, asynchrone Quelle für unsere Daten. Auf diese Weise werden Änderungen an einer Stelle automatisch in der ganzen Applikation übernommen, ohne dass wir uns über Events mit Aktualisierungen der UI beschäftigen müssen.
Warum Akita?
Der Vollständigkeit halber möchte ich darauf hinweisen, dass Akita natürlich nicht die einzige Lösung ist, um den State zu verwalten. Eine ebenso große und gut dokumentierte Lösung ist die Bibliothek NgRx. Diese implementiert das Redux-Pattern, welches dir aus anderen JS- Frameworks bekannt vorkommen könnte. Wenn dies der Fall ist, könnte sich ein Blick auf diese Bibliothek durchaus lohnen.
Ich persönlich bevorzuge den Akita-Store, da er sich in seiner Syntax eher an OOP anlehnt, die mir einfach vertrauter ist. Das soll aber nicht heißen, dass die Verwendung von Akita alternativlos ist.
Löst das die Probleme?
Im vorangegangenen Kapitel wurden einige relevante Probleme aufgezeigt, die uns mit der bisherigen Implementierung einigen Aufwand einhandeln könnten. Kann denn Akita diese Probleme beheben?
Das erste Problem war die applikationsweite Synchronisierung von Daten, also zum Beispiel die Änderung des Preises im Einkaufswagen, wenn ich diesen in der Warenliste verändern würde. Wie eingangs beschrieben, benutzen wir den Store als zentrale Datenhaltung. Solange wir also im Warenkorb nicht die ganzen Waren-Objekte ablegen, sondern Referenzen (z.B. über IDs) auf Elemente im Waren-Store nutzen, können diese asynchron angezeigt werden. Updaten wir dann eine Ware im Store, wird diese an allen Orten geändert, die sie abonniert haben.
Die Übernahme von Änderungen bei erfolgreicher Synchronisierung im Backend kann über den Service in Angular realisiert werden. Mit Akita sind wir in der Lage erst nach erfolgreicher Rückmeldung vom Backend die Änderungen in unserem Store abzulegen. Dazu nutzen wir die Pipe-Funktionalität von RxJs mit dem tap-Operator, um nach einem HTTP-Call das Ergebnis in den Store zu übernehmen. So werden die neuen Werte automatisch in der Applikation angezeigt, ohne dass wir alles neu laden mussten. Der einzige Fallstrick hierbei ist, dass wir uns darauf verlassen müssen, dass eine „Erfolgsmeldung“ des Backends auch wirklich eine Persistierung bedeutet.
Unerwünschte Seiteneffekte konnten bei unserer bisherigen Implementierung auftreten, da wir für synchrone Änderungen (z.B. des Warenkorbs) eine Referenz auf die Liste des Service weitergereicht haben. Wenn man im Warenkorb auf der Referenz Änderungen vornimmt,
werden diese automatisch an allen Stellen durchgeführt, die das Gleiche tun, ob wir nun wollen oder nicht. Der Akita Store vermeidet dies, indem die aus dem Store ausgelesenen Werte vor einer Änderung geschützt sind (read-only). Möchte ich an einer Stelle Bearbeitung erlauben, muss ich mich selbst darum kümmern. Und selbst dann wird so verhindert, dass ich aus Versehen Werte im Store verändere, wenn ich es nicht möchte.
Nötige Änderungen
Theorie ist schön und gut, aber wie nutzen wir den Store denn nun? Zuerst installieren wir die Dependency, wie in der offiziellen Dokumentation des Stores angegeben. Dadurch erhalten wir sowohl Zugriff auf den Store, als auch auf die Dev-Tools. Mit diesen können wir nun einen Store für die Waren einrichten:
ng g af items/item
Dies erzeugt uns einen Ordner mit dem Namen “state”, in dem wir folgende Dateien finden:
Store und Query werden in eigenen Dateien definiert, ebenso das Model und der Service. Da bereits ein Model und ein Service vorliegen, ersetzen wir den Inhalt im State einfach mit diesen. Die Methode zur Anfrage der Waren muss um eine Nebenoperation erweitert werden:
get() {
return this.http.get- this.itemsUrl).pipe(tap(entities => {
this.itemsStore.set(entities);
}));
}
So sorgen wir dafür, dass der Inhalt der Response sowohl zurückgegeben als auch im Store abgelegt wird. Natürlich wollen wir nicht, dass der Store jedes Mal neu gefüllt wird, wenn wir an verschiedenen Stellen Waren anzeigen. Wenn wir an anderen Stellen auf Werte des Stores zugreifen möchten, benutzen wir hierfür die Query. Diese lässt sich wie jeder andere Service injecten, bietet aber bereits eine Reihe von Methoden. Wollen wir nun also zum Beispiel im Einkaufswagen die Details einer Ware anzeigen, können wir diese wie folgt auslesen:
getCartItemDetails(itemId: number): Observable- {
// Select could technically return undefined, but in this hardcoded case it can't. It will be ignored later
return this.itemQuery.selectEntity(itemId);
}
Dies liefert uns ein Observable der Waren-Entity, die wir in der Waren-Übersicht angezeigt haben, ohne noch einmal eine Anfrage an das Backend zu senden. Die Query dient dabei nur lesenden Operationen und bietet eine Reihe von Möglichkeiten uns synchrone oder asynchrone Werte zurückzuliefern, nur bestimmte Felder auszulesen oder die Entities zu filtern. Aktionen, die den Store verändern, also Entities hinzufügen, updaten oder löschen, werden im Service definiert und sollten mit HTTP-Calls mit dem Backend synchronisiert werden. So müssen wir unsere Liste nicht ständig neu laden, haben aber in Front- und Backend einen synchronen State.
Den State für den Einkaufswagen setzen wir genauso auf, nur dass wir keine HTTP-Calls in dessen Service benötigen. Wir müssen nur Waren hinzufügen oder entfernen können.
Der leere Einkaufswagen
Anders als der Domain-State mit den Waren liegt unser Einkaufswagen-Store ausschließlich in der Angular-Applikation. Während wir unseren Shop bedienen, sieht mit der Einführung eines Stores alles so weit gut aus, wir können Waren auswählen, wieder aus dem Wagen entfernen und bekommen sogar den Gesamtpreis angezeigt. Es gibt nur ein Problem: Lade ich die Seite neu, ist der Einkaufswagen wieder leer.
Zur Demonstration ist dies nicht weiter schlimm, in einem Projekt könnte es aber unerwünschtes Verhalten sein. Ich hätte es lieber, wenn ich die Seite verlassen könnte und bei Rückkehr den Einkaufswagen automatisch wieder mit meinem letzten Stand gefüllt hätte. Ich kann den State allerdings nicht an mein „Backend“ übertragen, muss ihn also im Browser speichern.
Dieses Problem lässt sich schnell mit einem der Browser-Caches, dem LocalStorage oder SessionStorage lösen. Beide lassen sich in Angular schnell und einfach mit Key-Value-Paaren füllen. Damit wir aber nicht händisch Werte in die Stores ablegen müssen, bietet Akita die Möglichkeit, dies automatisiert zu erledigen. Dazu müssen wir nur unsere ”main.ts” anpassen.
const storage = persistState({include: ['cart']});
const providers =[{ provide: 'persistStorage', useValue: storage }]
if (environment.production) {
enableProdMode();
enableAkitaProdMode();
}
platformBrowserDynamic(providers).bootstrapModule(AppModule) .catch(err => console.error(err));
Mit „persistState()“ ist quasi bereits alles erledigt. Akita würde alle Stores bei jeder Änderung cachen (im Default im LocalStorage). Da wir aber nur unseren Einkaufswagen speichern möchten, geben wir an, welche Stores Akita berücksichtigen soll. Der Provider dient dazu in unserer Applikation auf den persistierten State zugreifen zu können. Nicht, um ihn auszulesen (das macht Akita automatisch), sondern um ihn auf Wunsch leeren zu können. Damit das Auslesen einwandfrei funktioniert, ist es wichtig, dass der Store bei Initialisierung nicht mit leeren Werten gefüllt wird, da Akita ansonsten diese „neuen“ Werte persistiert.
Mit dieser Änderung ist es auch wichtig, dass wir eine Lade-Logik implementieren. Ich habe auf Animationen verzichtet, sondern nur dafür gesorgt, dass unser Einkaufswagen erst angezeigt wird, wenn die Waren vom Backend geladen wurden. Da im Einkaufswagen nur noch mit Referenzen gearbeitet wird, brauche ich die Waren, damit meine Applikation keine Fehler wirft. Eine mögliche Änderung wäre nun, dass bei Anzeige des Einkaufswagens fehlende Waren nachgeladen und/oder leserliche Fehlermeldungen angezeigt werden, wenn zum Beispiel eine Ware aus dem Sortiment genommen wurde, während ich auf anderen Seiten unterwegs war. Aber das Thema Error-Handling würde hier den Rahmen sprengen.
Zusammenfassung
Abschließend möchte ich noch eine Frage beantworten, die sich vermutlich einige an diesem Punkt stellen werden: Muss ich das alles immer machen? Nein. Vernünftiges State- Management vereinfacht vor allem das Weiterreichen von Daten zwischen Routen und das Teilen von Informationen in mehreren Komponenten. Wenn ich das nicht brauche, ist es auch nicht unbedingt nötig mit Kanonen auf Spatzen zu schießen. Würde ich zum Beispiel meinen Shop um eine User-Profil-Seite erweitern, in welcher der Nutzer sein Heimatland in einem Dropdown auswählen kann, das mit einer Liste aus dem Backend befüllen wird, so brauche ich keinen Store für die Länderliste. Ich bräuchte diese Liste nur an dieser einen Stelle und nirgendwo sonst. Den einzigen Vorteil, den ein Store mir brächte, wäre, dass ich die Liste nicht jedes Mal neu laden muss, wenn der User auf die Profil-Seite navigiert. Wie wichtig das ist, hängt aber stark vom Use-Case ab.
Ich hoffe ich konnte zeigen, dass die Verwaltung des States einer Applikation mit dem Akita- Store vergleichsweise einfach zu bewerkstelligen ist. Dabei verhindert er, wenn man ihn bereits früh implementiert, dass einige Fehler oder Probleme bei der weiteren Entwicklung auftreten. Durch seine einfache Einrichtung lässt sich der Store jedoch auch später noch in eine bestehende Applikation einbetten, ohne dadurch viel Aufwand zu erzeugen.
Der Akita-Store kann allerdings auch noch mehr, als ich hier anschneiden konnte. Sieh dir gerne die Dokumentation an und versuch am besten den Store in einem Projekt einzusetzen. Wenn du gerade erst mit Angular begonnen hast: Erweitere die Tour of Heroes um einen Store, der die Helden verwaltet. Falls ihr schon Berührungspunkte mit State-Management hattet: Was sind eure Erfahrungen? Habt ihr Fragen? Habt ihr weitere Tipps für Einsteiger? Schreibt es gerne in die Kommentare.
Author