//

Modernes Data Fetching mit Redux Toolkit Query

10.10.2022 | 9 Minuten Lesezeit

Das vor sieben Jahren erstmals veröffentlichte Redux wurde bereits vor vier Jahren mit Redux Toolkit (RTK) modernisiert. Im Juni 2021 erreichte Redux dann die nächste Evolutionsstufe, indem mit Redux Toolkit Query eine dedizierte Data-Fetching-Lösung ergänzt wurde. Mit Hinblick auf die steigende Beliebtheit von Data Fetching Libraries wie react-query (30k GitHub Stars), stellen wir in diesem Artikel RTK-Query vor. Es bildet eine Alternative, die im Redux-Kosmos verdrahtet ist.

Der alte Redux-Schmerz

"I have to add a lot of packages to get Redux to do anything useful"

Mit dem Release von RTK haben die Entwickler die größten Schmerzen beseitigt, die die Nutzer von Redux damals hatten. Dass diese Schmerzen auch in der README des Redux GitHub-Projekts aufgeführt sind, zeugt davon, dass sie bei der Entwicklung eine große Rolle gespielt haben.

Einer der Kritikpunkte am Vanilla Redux (also die Redux-Version vor RTK) war: "Configuring a Redux store is too complicated". Dieser Punkt ist besonders pikant, da der Store mehr oder minder das Herzstück im Redux-Flow bildet. So mussten selbst für eine Konfiguration mit simplen Middlewares verschiedene Redux-Funktionen aneinander gestöpselt werden (applyMiddleware, compose, createStore, …).

Wenn man schon etwas länger mit React arbeitet, dann kommt einem sicher bekannt vor, dass die node_modules sehr schnell zu einem Zoo von Dependencies werden, die man verwalten muss. Bei Redux war das ein weiterer Kritikpunkt: "I have to add a lot of packages to get Redux to do anything useful". Umständlich war hier, dass Redux selbst zu wenig mitgebracht hat, um etwas komplexere Use-Cases zu unterstützen. Das schnelle Füllen der node_modules ist natürlich nicht gelöst (🙈), allerdings ist das Installieren von Redux deutlich vereinfacht worden und die Vielseitigkeit der mitgebrachten Funktionalitäten enorm gestiegen. So bringt create-react-app beispielsweise ein Template für Redux mit, das die Installation von RTK übernimmt und auch die manuelle Installation des aktuellen RTK-Moduls hat für die meisten Anwendungsfälle schon alles an Bord. Hinzu kommt, dass sich das Redux-Team folgendes auf die Fahnen geschrieben hat:

"We can't solve every use case, but in the spirit of create-react-app, we can try to provide some tools that abstract over the setup process and handle the most common use cases, as well as include some useful utilities that will let the user simplify their application code."

Der letzte und wahrscheinlich größte Kritikpunkt an Vanilla Redux ist die Menge an Boilerplate-Code. Reducer waren oftmals riesige switch-Blöcke, die den aktuellen State immer mittels Spread-Operator "immutable" gemacht haben, Actions sollten separat definiert werden, Action-Creator sollten genutzt werden, Entwickler mussten den Store definieren sowie konfigurieren. Und wenn man das Ganze dann noch mit TypeScript kombinierte, sind die Type-Definitions häufig schlagartig explodiert. Dass man Data Fetching damals gerne mit sogenannten Redux Sagas machte, hat diesem Kritikpunkt nicht unbedingt gut getan. Zusätzliche Saga-Listener mussten definiert werden, welche Actions anstelle der Reducer verarbeiten. Diese kümmerten sich dann um die Datenbeschaffung und schrieben die geladenen Daten anschließend über weitere Actions in den Store, um dann in Komponenten darauf zugreifen zu können. Auch Alternativen wie redux-thunk oder eigene Implementierungen der Data-Fetching-Schicht konnten den Boilerplate-Code nicht reduzieren und (Spoiler!) Sagas finden auch heute noch in RTK Anwendung. Aus diesem Grund wird der weitere Vergleich auf RTK-Query versus Sagas beschränkt.

Die Idee, Data Fetching mittels Sagas zu realisieren, ist per se nicht schlecht. Allerdings muss dafür zumindest ein rudimentäres Verständnis der JavaScript Generator Functions aufgebaut werden, welche nur selten Verwendung finden (die coolen Typen von Papperlapapp haben dazu ein super Video gemacht). Darüber hinaus sind Sagas dafür zuständig, den Store über den Zustand der Netzwerk-Requests auf dem Laufenden zu halten. So kann eine Anwendung entsprechend Feedback geben, dass Daten aktuell geladen werden oder dass das Laden fehlgeschlagen ist (was natürlich wieder über Actions abläuft). Die UI-Komponenten können dann entsprechend auf die Store-Updates reagieren und Loading-Spinner/Skeletons sowie Fehlermeldungen anzeigen. Anstrengend hierbei ist, dass man pro Saga mindestens zwei Actions definieren muss ("LadeDaten" + "DatenGeladen"). Außerdem müssen wir im Store neben den eigentlichen Daten unter Umständen auch pro Request entsprechende Flags vorhalten, die den Status derselben widerspiegeln. Auch das Testen von Komponenten, die Sagas triggern, ist nicht wirklich einfach und benötigt zusätzliche Testing-Tools wie redux-saga-tester.

Beim Entwickeln von RTK gab es also einige Baustellen, die ausgebessert werden mussten und das selbstverständlich mit möglichst wenig Code, sowie möglichst viel Utility für die Anwender:

  • Store-Konfiguration vereinfachen
  • Mehr Funktionalitäten selbst mitbringen
  • Weniger Boilerplate
  • Data Fetching eleganter gestalten

Wie funktioniert RTK-Query?

Die grundlegende Motivation hinter RTK-Query lässt sich sehr gut aus dem vorherigen Abschnitt ableiten. Alles sollte mit weniger Code und am besten noch mit mehr Funktionalität umgesetzt werden. Die notwendigen Schritte, um Daten von einem Service zu bekommen und dies dennoch strukturiert und skalierbar zu halten, wurden hierbei auf ein Minimum reduziert. Die grundlegende Idee, Data Fetching mit einer asynchronen Schicht wie Redux-Sagas oder Redux-Thunk umzusetzen, welche mit dem zugrunde liegenden Redux Store und dessen Actions zusammenarbeitet, wurde hierbei allerdings beibehalten. Die Komplexität, die dies mitbringt, wird allerdings durch Redux-Query vor uns versteckt, sodass das Thema Data Fetching nun wesentlich deklarativer anmutet als zuvor.

Vergleich Redux-Saga (links) und RTK Query (rechts)

Das Anbinden eines neuen Endpunktes erfolgt nun über ein einmaliges Beschreiben der Schnittstelle und alles Weitere wird für uns generiert. Die geladenen Daten werden dennoch automatisch in unseren Redux Store geschrieben, ohne dass wir einen Wust an Actions orchestrieren müssen, die schlussendlich die Daten in unseren Reducer münden.

RTK-Query Hands-on

In diesem Abschnitt wollen wir uns RTK-Query in der Praxis anschauen. Wie bereits weiter oben erläutert, war das Einbinden von Vanilla Redux mit vielen kleinen Schritten verbunden. Durch Redux-Toolkit wurde das ganze wesentlich einfacher, sodass wir auch mit RTK-Query davon profitieren können. Für den klassischen Quick-Start bietet das Redux-Team ein create-react-app-Template an, das wir hier direkt nutzen möchten.

npx create-react-app my-app --template redux-typescript

Als Beispiel wollen wir eine To-do-App bauen und fangen damit an, die To-dos von unserem Server zu lesen. Hierfür starten wir mit dem Definieren unseres Endpoints.

1export interface Todo {
2    id: number;
3    title: string;
4    completed: boolean;
5}
6
7const BASE_URL = 'https://jsonplaceholder.typicode.com';
8
9export const todoApi = createApi({
10    reducerPath: 'todos',
11    baseQuery: fetchBaseQuery({baseUrl: BASE_URL}),
12    endpoints: (builder) => ({
13            getTodos: builder.query<Todo[], void>({query: () => '/todos'})
14        }
15    )
16});
17
18export const {useGetTodosQuery} = todoApi;
19

Der Code zeigt unseren Endpunkt, um unsere To-dos zu laden. Das schöne ist: Wir sind nun schon fertig! Um die Schnittstelle noch etwas lesbarer zu gestalten sowie den deklarativen Ansatz noch mehr herauszuarbeiten, haben wir Typescript verwendet. Im Codebeispiel oben konfigurieren wir folgendes:

  • reducerPath: Der Name unserer Daten im Redux-Store
  • baseQuery: Die base-URL unseres Endpunktes. Die entsprechenden Ressourcen werden anschließend durch unseren getTodos-Endpunkt mit einem zusätzlich /todos spezifiziert.
  • endpoints: Mithilfe des builders werden unsere Endpunkte spezifiziert sowie typisiert
  • useGetTodosQuery: Unser automatisch generierter Hook, den wir direkt in unsere Komponente einbinden können, um mit den Daten zu arbeiten.

Im Folgenden sehen wir, wie der generierte Hook angewendet werden kann.

1const TodoListComponent = (): JSX.Element => {
2    const {data, error, isLoading} = useGetTodosQuery();
3
4    if (isLoading) {
5        return <div>Is loading...</div>;
6    }
7
8    if (error) {
9        return <div>There is an error...</div>;
10    }
11    
12    return (
13        <div>
14            {data && data.map(
15                (todo, index) => <p key={index}>{todo.title}</p>
16            )}
17        </div>
18    );
19};
20
21export default TodoListComponent; 
22

Durch das Destructuring bekommen wir eine Reihe automatisch generierter Properties, die für unseren Anwendungsfall sehr sinnvoll sind.

  • data: Enthält in unserem Fall unser Todo-Array, welches auch entsprechend typisiert ist.
  • error: Enthält etwaige Fehler die während des des Requests aufgetreten sind
  • isLoading: Ein Boolean, der abbildet ob der Request noch läuft

Diese Properties enthüllen einen großen Vorteil von RTK-Query gegenüber einer selbst implementierten Variante mit z. B. Sagas. Wir haben sofort Zugriff auf den Status unseres Requests und können dementsprechend in der Oberfläche darauf reagieren (z. B. durch das Anzeigen eines Loading-Spinners). Wir sparen uns die Implementierung einer globalen Lösung dieser Fälle und machen unsere Komponente dadurch äußerst lesbar und selbsterklärend. Zusätzlich zu diesen drei Properties gibt es noch weitere Felder, die wir verwenden können, um mögliche Edge Cases abzubilden, z. B. currentData, wodurch wir den letzten Datenfetch einer Query erhalten um ein mögliche Differenz abfragen zu können (hier die komplette Liste).

Folgendermaßen erweitern wir unsere verfügbaren Queries um einen getTodoById.

1endpoints: (builder) => ({
2    getTodos: builder.query<Todo[], void>({query: () => '/todos'}),
3    getTodoById: builder.query<Todo, number>({query: (id) => `/todos/${id}`})
4})
5

Dies illustriert die einfache Erweiterbarkeit unserer API-Schicht, denn ohne RTK-Query wären folgende Anpassungen im Code notwendig:

  • Erstellung mehrerer actions inkl. action creator
  • Erweiterung des Reducers
  • Implementierung der asynchronen Logik die zum Fetchen nötig ist (saga, thunk o. Ä.)

Und bei dieser Auflistung ist noch nicht einmal betrachtet, dass wir das ganze auch testen müssen (Ich sag nur: Typos in den actions!).

Im Folgenden wollen wir uns noch einmal ein Beispiel anschauen, bei dem wir Daten versenden, anstatt sie zu empfangen. Hierfür werden sog. Mutations benutzt, die im Detail sehr mächtig sind, aber im einfachen Fall auch sehr schnell implementiert werden können.

1export const todoApi = createApi({
2    reducerPath: 'todos',
3    tagTypes: ['Todos'],
4    baseQuery: fetchBaseQuery({baseUrl: BASE_URL}),
5    endpoints: (builder) => ({
6            getTodos: builder.query<Todo[], void>({
7                query: () => '/todos',
8                providesTags: () => [{type: 'Todos', id: 'allTodos'}]
9            },),
10            addTodo: builder.mutation<Todo, Partial<Todo>>({
11                query: (todo) => ({
12                    url: 'todos',
13                    method: 'POST',
14                    body: todo,
15                }),
16                invalidatesTags: [{ type: 'Todos', id: 'allTodos' }],
17            })
18        }
19    )
20});
21
22export const {useGetTodosQuery, useAddTodoMutation} = todoApi;
23

Die POST-Query erzeugen wir mit builder.mutation, dessen Aufruf wir den URL-Endpunkt, die HTTP-Methode (POST, PUT oder PATCH), sowie den eigentlichen body (aka unser neues To-do) mitgeben. Ebenfalls hinzugekommen im oberen Codebeispiel (allerdings optional) sind tagTypes, providesTags und invalidatesTags, welche für das Steuern des Cachings notwendig sind. Das Definieren von tagTypes ermöglicht es uns, ganze APIs bzw. Untermengen derselben eindeutig zu identifizieren, providesTags weisen einzelne Queries aus. Mutations geben durch invalidatesTags an, welche konkreten APIs/Queries sie invalidieren. "Invalidieren" heißt in diesem Kontext, dass diese Queries erneut ausgeführt und somit die gelieferten Daten aktualisiert werden.

1const TodoListComponent = (): JSX.Element => {
2    const {data, error, isLoading} = useGetTodosQuery();
3    const [addTodo] = useAddTodoMutation();
4
5    if (isLoading) {
6        return <div>Is loading...</div>;
7    }
8
9    if (error) {
10        return <div>There is an error...</div>;
11    }
12
13    return (
14        <div>
15            <button onClick={() => addTodo({title: 'Some fancy Todo'})}>
16                Add Todo
17            </button>
18            {data && data.map(
19                (todo, index) => <p key={index}>{todo.title}</p>
20            )}
21        </div>
22    );
23};
24
25export const {useGetTodosQuery, useAddTodoMutation} = todoApi;
26

In unserem konkreten Beispiel wird beim Klicken auf den "Add Todo"-Button ein neues To-do hinzugefügt und unsere Query getTodos invalidiert. Dadurch wird beim nächsten Ausführen des useGetTodosQuery-Hooks nicht der Cache angefragt, sondern alle To-dos neu gefetcht. Das Default-Verhalten würde dies unterbinden, da die Daten nur angefragt werden, wenn diese noch nicht vorhanden oder veraltet (sofern ein zeitliches invalidieren konfiguriert ist) sind. Somit tauchen frisch hinzugefügte To-dos direkt in unserer To-do-Liste mit auf.

Dies bildet eine einfache Art, das Caching unserer Daten zu realisieren und unsere Netzwerkanfragen auf ein Minimum zu reduzieren, ohne aufwendige Logik hierfür implementieren zu müssen (oder ggf. weitere Dependencies zu nutzen).

Schlussworte

Ganz allgemein gefällt uns RTK-Query sehr gut. Es ist sehr einfach zu nutzen und noch besser zu erweitern. Der benötigte Boilerplate-Code wird bereits durch RTK massiv reduziert. Mit RTK-Query wird dieses Konzept sinnvoll erweitert und zudem auf die API-Schicht ausgeweitet, um auch diese deutlich schlanker zu gestalten. Zudem gibt uns RTK-Query eine Reihe von Werkzeugen an die Hand, um übliche Funktionalitäten wie Caching, Error-Handling und isLoading usw. nicht selbst implementieren zu müssen.

Speziell wenn Redux bereits im Einsatz ist, fühlt sich der Umstieg auf RTK-Query äußerst natürlich an. Darüber hinaus integriert sich RTK-Query sehr gut in bestehende Lösungen, da nur einzelne Teile (z.B. Sagas) ausgetauscht werden müssen und dies sogar schrittweise realisiert werden kann.

Mit Redux hat man außerdem eine etablierte und gut maintainte Library als Unterbau, wodurch das grundlegende Verständnis der Konzepte schon bei vielen Entwicklern vorhanden ist. Die verfügbare Dokumentation ist ebenfalls sehr umfangreich und erleichtert den Einstieg.

Beitrag teilen

Gefällt mir

7

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.