Im React-Ökosystem gibt es verschiedene Wege, mit globalem State umzugehen. Redux, MobX, Context und viele mehr. In diesem Blogpost werfen wir heute einen Blick auf Jotai.
Was ist Jotai?
Jotai nähert sich dem Thema State mit Atomen. Wer Recoil kennt, hat das schon mal gehört. Einen Einstieg in die zugrunde liegenden Konzepte findet ihr in der Jotai-Doku.
Anstatt einen zentralen Store aufzubauen, baut man einzelne Atome auf, die überall in den React-Komponenten aufgerufen werden können. Der globale State ist also die Kombination aller Atome. Jotai kann auf Grund dieser atomaren Struktur und der Abhängigkeiten zwischen den Atomen Render-Zyklen optimieren und somit unnötige Re-Renders vermeiden.
Es ist vollständig kompatibel mit TypeScript und React 18, und baut auf Hooks auf. Somit lässt sich Jotai sehr einfach in eine React-Anwendung einbauen.
Und wie funktioniert das jetzt?
Sehen wir uns zunächst einmal an, wie das in einer klassischen React-Redux-Anwendung aussieht.
(Achtung: Pseudo-Code incoming!)
function MyComponent() { const [state, setState] = useState(false); const dispatch = useDispatch(); const handleSubmit = useCallback((something) => dispatch(something)); return <ChildComponent open={state} onClose={() => setState(false)} onSubmit={handleSubmit} />; }
In diesem Code-Schnipsel sieht man zum einen den lokalen State aus Vanilla-React, der von MyComponent
an die Kind-Komponente weitergegeben wird. Zum anderen sieht man die Redux-Store-Manipulation, die mittels dispatch
passiert. Natürlich könnte der ganze handleSubmit
auch im ChildComponent
aufgebaut werden. Für dieses Beispiel lassen wir das jetzt aber mal so.
Was nicht in dem Beispiel zu sehen ist, ist wie der Redux-Store aufgebaut wird; das würde den Rahmen sprengen. Grundsätzlich ist das nicht schwer: Man baut einen globalen Store mit Actions und Selektoren, über die der Store verändert oder ausgelesen werden kann. Falls ihr aber genauer wissen wollt, wie das geht, kann ich nur empfehlen, dass ihr euch das Redux-Toolkit anschaut. Sucht nicht einfach nur nach Redux – da gibt’s zu viele veraltete Infos da draußen …
Umsetzung in Jotai
Schauen wir uns jetzt also mal Jotai an:
const someAtom = atom(); function MyJotaiComponent() { const [state, setState] = useState(false); const [value, setValue] = useAtom(someAtom); const handleSubmit = useCallback((something) => setValue(something)); return <ChildComponent open={state} onClose={() => setState(false)} onSubmit={handleSubmit} />; }
Das sieht ja jetzt sehr ähnlich aus. Zumindest in MyComponent
. In Zeile 1 erstellen wir ein sehr generisches Atom, in dem wir Jotais atom()
-Funktion aufrufen.
In MyComponent
hat sich an dem lokalen Komponenten-State von Vanilla-React nichts geändert. Der funktioniert wunderbar in Verbindung mit Jotai. Anstatt useDispatch()
benutzen wir jetzt aber useAtom()
, welches uns nicht nur den aktuellen Wert des Atoms gibt, sondern auch eine Setter-Funktion für das Atom. Und mit dieser Setter-Funktion bauen wir dann das handleSubmit
.
Aber wir verwenden den value
in unserem Code gar nicht, also ändern wir die entsprechende Zeile in
[...] const setValue = useSetAtom(someAtom); [...]
UseSetAtom()
gibt uns nur den Setter zurück. Wenn wir nur den Wert wollen, nutzen wir übrigens useAtomValue()
.
Ein Wort der Warnung
Schon in dem kurzen Beispiel aus dem zweiten Code-Block sieht man einen fundamentalen Unterschied zu Redux: Zeile 1 baut unseren globalen State – unseren „Store“ – auf. Natürlich hat unser „Store“ nur einen Eintrag, aber mehr ist dafür grundsätzlich nicht nötig.
An dieser Stelle direkt ein Wort der Warnung: Wie in vielen Tech-Stacks muss man sich Gedanken darüber machen, wo und wie man seinen Code auf die einzelnen Dateien verteilt. Die Tatsache, dass die Gesamtheit aller Atome unser globaler State ist, mag dazu verleiten, alle Atome in einen state-Ordner zu packen. Oder die Atome bei den Komponenten zu definieren, die sie brauchen oder initialisieren. Beides ist valide, solange man es einheitlich macht und seinen Code am Ende auch wiederfindet.
Der Globale State in einem Atom
Man kann natürlich auch den lokalen State in ein Atom packen. Für unseren Code hat das keine großen Auswirkungen:
const stateAtom = atom(false) function MyJotaiComponent() { const [state, setState] = useAtom(stateAtom); [...]
Aber warum sollte ich das machen? Damit wird meine kleine Info, ob die ChildComponent
offen oder zu ist, ja auf einmal zum globalen State?
Richtig. Macht aber nichts.
Bei Jotai gibt es schließlich keinen Store. Es gibt kein Objekt, das mit allen seinen Eigenschaften initialisiert werden muss. Es gibt nur einzelne Atome, die zusammen sozusagen einen „virtuellen Store“ aufbauen. Und wie eingangs schon erwähnt, können über diese Atome und ihre Abhängigkeiten überflüssige Re-Renders vermieden werden.
Reacts useState()
hat aber trotzdem noch seinen Platz, denn manchmal braucht eine Komponente einfach einen lokalen State, auf den kein anderer Teil der Anwendung Einfluss nehmen muss oder davon abhängt.
Der größte Vorteil?
Grundsätzlich ist jedoch meiner Meinung nach bei Jotai ein großer Vorteil, dass man sich bei jeder Property, die man klassisch an Kind-Komponenten weitergibt, die Frage stellen kann:
Kann die Information auch über ein Atom geholt werden, so dass die Kind-Komponente direkt auf den Wert zugreifen kann, oder muss diese Information von den Eltern kommen?
Mit Jotai muss man keine Property-Glossare mehr quer durch den Komponenten-Baum schleifen. Komponenten, die bestimmte Informationen nicht brauchen, müssen sie nicht trotzdem bekommen, um sie weiterzureichen. Das macht den Code sehr viel leichter verständlich und lesbarer.
Geht bei den Atomen noch mehr?
Kurze Antwort: Ja. Viel mehr.
Ich werde in dem Post ein paar coole Dinge vorstellen, aber alles hat natürlich keinen Platz hier. Wer neugierig ist, kann sich in der Doku austoben. Allerdings noch ein kleiner Hinweis zur Doku: Nutzt die Suche! Manche Sachen sind leider noch nicht über die Navigation zu finden …
Auf diese Themen werde ich genauer eingehen:
- Eigene Getter und Setter für Atome bauen
- Abgeleitete Atome und Dependency-Bäume
- Atome, die ein API anzapfen
- Atome, die in localStorage persistieren
- Focus-Atome
- Atom-Familien
Da ist viel zu tun, also fangen wir an!
Eigene Getter und Setter
Geben wir uns einfach mal direkt die volle Ladung und schauen eine Implementierung von eigenen Gettern und Settern an:
const fooAtom = atom<boolean>(false); const barAtom = atom<{foo: boolean}>( (get) => ({foo: get(fooAtom)}), (get, set, update: {foo: boolean}) => { const foo = get(fooAtom); if (foo !== update.foo) { set(fooAtom, update.foo); } } );
In Zeile 1 definieren wir als ersten fooAtom
. Diese Notation kennen wir schon aus vorherigen Beispielen, hier wurden nur TypeScript-Typen ergänzt. In diesem Fall wird atom()
mit einem Initialwert aufgerufen. Danach definieren wir uns unser barAtom
. Hier rufen wir atom()
mit zwei Funktionen auf:
Die erste bringt als Parameter get
mit, mit dem wir auf andere Atome zugreifen können. Diese Funktion muss einen Wert zurückgeben. In unserem Fall einfach nur ein Objekt {foo: boolean}
, wobei wir mittels get
das fooAtom
auslesen. Diese Funktion kann aber natürlich beliebig komplex werden.
Die zweite Funktion bringt als Parameter get
, set
und update
mit. Get
kennen wir schon. Mit set
können wir den Wert anderer Atome verändern. Und update
ist der neue Wert, der verarbeitet werden will. In unserem Beispiel schauen wir einfach, ob sich der neue Wert von dem alten unterscheidet. Und wenn dem so ist, dann updaten wir.
Anzumerken ist hier, dass Jotai aus den mitgegebenen Typdefinition sehr gut den finalen Typen des Atoms inferieren kann. Man muss sich da also nicht zu sehr um die doch recht komplizierten Typen der Atome kümmern.
Abgeleitete Atome und Dependency-Bäume
In Redux wird der Store immer mit einem Wert initialisiert. Der gesamte Store, egal wie groß, ist also immer da. Bei Jotai ist das nicht so: Hier kommen abgeleitete Atome – oder Derived Atoms – ins Spiel.
Wir haben auch schon ein abgeleitetes Atom gesehen: Im vorherigen Listing ist barAtom
von der Basis fooAtom
abgeleitet. Natürlich kann man nun auch wieder von barAtom
ableiten und so weiter. Doch was genau nützt uns das jetzt?
Im genannten Beispiel-Listing werden die beiden Atome erst initialisiert, wenn eins davon auch tatsächlich in unserer Anwendung benötigt wird.
Stellen wir uns folgenden Atom-Abhängigkeitsbaum vor:
Wenn unser Nutzer auf die Seite navigiert, wollen wir seine UI-Settings laden. Das entsprechende Atom muss wissen, ob der User eingeloggt ist, also wird das isLoggedInAtom
initialisiert. Damit dieses einen Wert zurückgeben kann, braucht es das userCredentialsAtom
, also wird das initialisiert. Wenn der Nutzer nun weiter auf sein Profil navigiert, wird das Atom für das Profilbild abgefragt. Dieses benötigt die Daten aus dem profileAtom
, welches wissen muss, ob der User eingeloggt ist. Da das isLoggedInAtom
vorher schon gebraucht wurde, können diese Daten jetzt aus dem Cache gelesen werden.
Interessant ist auch, dass Atome nicht ewig im Speicher gehalten werden. Unser profilePictureAtom
wurde zum Beispiel in der Profil-Komponente initialisiert. Wenn diese Komponente verlassen und aus dem DOM ausgehängt wird, wird auch das Atom aus dem Cache geräumt. Das isLoggedInAtom
wurde allerdings auf oberster Ebene unsere App initialisiert, also bleibt es im Cache erhalten. Dieses Verhalten muss man im Hinterkopf behalten; richtig eingesetzt verringert es aber den Speicherbedarf unserer Applikation.
Atome, die ein API anzapfen
Da wir uns schon angeschaut haben, wie wir eigene Getter und Setter definieren, wollen wir in unseren Atomen auf APIs zugreifen. Jotai benutzt dafür Tanstack-Query, welches einige vielleicht noch als React-Query kennen. Damit können wir einfach einen API-Endpunkt auslesen:
const [queryAtom] = atomsWithQuery((get) => { const currentSomething = get(currentSomethingAtom); return { queryKey: ['queryAtom', currentSomething], queryFn: async () => { const response = await fetch('https://some.api.url/${currentSomething}'); // Achtung: Hier sollten Backticks statt ' stehen, // aber das Syntax-Highlight-Plugin kann damit nicht umgehen! return response.json(); }, }; });
In atomsWithQuery()
definieren wir nur einen Getter, da wir das API konsumieren und an dieser Stelle nicht verändern wollen. Die Get-Funktion muss ein Objekt mit einem queryKey
und einer queryFn
zurückgeben. Der queryKey
wird intern genutzt, um die Daten zu cachen. Es ist also sinnvoll, den Schlüssel nicht zu allgemein zu halten, so dass die Daten auch richtig identifiziert werden können.
In der queryFn
kann man dann ganz normal fetch
nutzen (oder Axios oder jede andere Interface-Lib), um an die Daten zu kommen. Hier kann man dann zum Beispiel auch Validierung, spezielles Error Handling oder weitere Datenmanipulation einbauen. Und wenn man Jotai ein paar Typ-Informationen vom API mitgibt, kann auch der Atom-Typ wieder richtig inferiert werden.
Atome, die in LocalStorage persistieren
Manchmal ist es notwendig, Daten im Browser zu persistieren, so dass sie auch bei einem Reload oder Tabwechsel erhalten bleiben. In Jotai geht das so:
const storageAtom = atomWithStorage('storageKey', false)
Per default ist atomWithStorage
auf den LocalStorage gesetzt. Alles was wir tun müssen, steht also im Listing. Anzumerken ist, dass einige Atoms von Jotai – wie zum Beispiel das StorageAtom
– „resetable Atoms“ sind. Da lohnt sich ein Blick in die Doku.
Man kann hier aber natürlich mehr konfigurieren, wenn man das will: Neben dem Schlüssel und dem Initialwert, die auf jeden Fall gesetzt sein müssen, kann man auch den Storage konfigurieren. Anstelle des LocalStorage kann man auch den SessionStorage verwenden. Oder einen AsyncStorage, wenn man React Native implementiert. Oder man baut sich einen komplett eigenen Storage, der das Storage Interface implementiert.
Focus-Atome
Focus-Atome kommen aus der Jotai-Lib jotai-optics. Ein solches Atom ist ein abgeleitetes Atom, das nur einen bestimmten Teil seines Eltern-Atoms fokussiert. Schauen wir uns ein Beispiel an:
type LargeType = { foo: boolean; bar: string | number; baz: SomeOtherObjectType; ... }; const largeAtom = atom<LargeType>({foo: true, bar: 42, baz: ...}) const fooAtom = focusAtom(largeAtom, (optic) => optic.prop('foo'))
Und benutzt werden kann das Ganze dann wie gewohnt:
const [foo, setFoo] = useAtom(fooAtom);
Was ist das Besondere daran? Wenn man fooAtom
verändert, verändert sich largeAtom
mit. Und wenn sich largeAtom
ändert und foo
von der Änderung betroffen ist, dann ändert sich fooAtom
mit. Die Änderungen werden also in beide Richtungen propagiert.
Ein weiterer Vorteil ist, dass es manchmal den Code umständlicher macht, wenn man mit großen Objekten arbeiten muss. Unser fooAtom
arbeitet auf simplen Boolean-Werten – das große Objekt, auf das fokussiert wird, ist uns egal.
Atom-Familien
Alle bisherigen Atome, die wir uns angesehen haben, waren insofern simpel, dass ihre einzige Abhängigkeit zu anderen Atomen war. Auf diese können wir über einen Getter einfach zugreifen. Was ist aber, wenn unser Atom von einem anderen, externen Argument abhängt? Zum Beispiel eine Funktion wie getUserById()
. Natürlich könnten wir die UserId in ein Atom speichern, aber nehmen wir an, dass wir das nicht wollen.
In so einem Fall können Atom-Familien nützlich sein. Schauen wir auf eine Implementierung:
const userByIdAtom = atomFamily( (id: string) => { const [baseQueryAtom] = atomsWithQuery((get) => { return { queryKey: ['userByIdAtom', id], queryFn: async () => { const response = await fetch('https://some.user-info.url/${id}'); // Achtung: Hier sollten Backticks statt ' stehen, // aber das Syntax-Highlight-Plugin kann damit nicht umgehen! return response.json(); }, }; }); return baseQueryAtom; }, (a, b) => a === b, );
In einer Atom-Familie definieren wir als erstes eine Funktion, die uns ein Atom zurückgibt. Im Beispiel ist es ein Query-Atom, das wir ja schon kennen. Als zweites definieren wir eine Vergleichsfunktion, im Beispiel (a, b) => a === b
. Die Vergleichsfunktion ermöglicht es der Atom-Familie zu wissen, ob die aktuelle UserId schon einmal verwendet wurde, um ein Atom zu bauen. Wenn dem so ist, wird der Wert aus dem Cache geholt.
Benutzt wird das ganze wie folgt:
const { userId } = props; const user = useAtomValue(userByIdAtom(userId));
Und entsprechend der UserId, die über die props
gesetzt wird, werden die richtigen User-Information geholt.
Ich bin platt. Wer noch?
Das war jetzt eine ganze Menge und natürlich kann ich nur einen Überblick geben. Wer Lust auf mehr hat – hier ist nochmal die Doku. Und wer lieber den Code lesen mag, wird auf GitHub fündig – ich finde diese Lib ja sehr schön geschrieben. Da kann man verstehen, was passiert.
Es gibt bei Jotai noch sehr viel mehr zu entdecken, als das was hier im Artikel steht. tRPC-Anbindung, hashAtoms (die an die window.location
gekoppelt sind), valtio (wenn man einen Proxy braucht) oder Moleküle (wenn man alles über Atome weiß und noch hungrig ist).
Ich hoffe, dieser Einstieg hat Lust gemacht Jotai einmal auszuprobieren und hilft dabei, sich zu Beginn zurecht zu finden.
Schreibe einen Kommentar