Die Migration von Apollo GraphQL zu React Query
Speziell wenn es um größere Datenabfragen geht, stößt man mit dem Apollo-Client auf einige Hürden, weshalb wir einen Technologiewechsel entschieden haben. In dieser Fallstudie wollen wir die Überlegungen und den Prozess des Austauchs unseres GraphQL-Clients Apollo durch React Query in einem unserer Projekte dokumentieren.
Warum überhaupt Apollo GraphQL?
In den letzten Jahren war das kostenlose Apollo GraphQL unser bevorzugter GraphQL Client in den meisten unserer React Web-Projekte. Wir haben GraphQL in einem frühen Stadium des Ökosystems eingeführt und Apollo war eine der wenigen Optionen, die eine gute Abstraktion für die Client-Seite der Kommunikation über GraphQL bot. Neben der Abwicklung der REST-Kommunikation mit dem GraphQL-Endpunkt bietet Apollo eine ausgefeilte Lösung für das Ergebnis-Caching. Bei einem tief vernetzten Graphen mit einem relativ großen Datensatz, der Client-seitig verfügbar sein muss (> 50.000 miteinander verbundene Entitäten im gesamten Graphen), schien das von Apollo bereitgestellte normalisierte Caching die durch die große Datenmenge auftretenden Probleme zu lösen. Eines dieser Probleme war die Aktualisierung der entsprechenden Daten nach der Durchführung von Datenmutationen. Der normalisierte Apollo-Cache kümmert sich automatisch um die Aktualisierung des entsprechenden Cache-Eintrags (und damit um die Benachrichtigung aller damit zusammenhängenden Komponenten), solange das Mutationsergebnis die aktualisierten Entities enthält. Die Updates einer einzelnen Entität nach erfolgreicher Mutation können sehr einfach sowohl optimistisch als auch retroaktive auf dem Cache angewandt werden, sodass nicht nur Queries direkt auf die aktualisierte Entität, sondern auch tiefere Queries, die die Entität nur verschachtelt enthalten, aktualisiert werden. Apollo bietet auch eine einfache API, um bestimmte Queries nach erfolgreichen Mutationen explizit neu zu laden. Jede Aktualisierung des normalisierten Caches löst automatisch ein Rerender von allen React-Komponenten aus, die von den entsprechenden Daten abhängen. Die nahtlose Integration in den GraphQL Code Generator macht die Arbeit mit dem Apollo Client zu einem Kinderspiel, da aus GraphQL Schema und allen im Projekt verwendeten Queries und Mutationen vollständig typisierte React Hooks on the fly generiert werden können.
Probleme, die wir mit dem Apollo-Client hatten
Der von Apollo bereitgestellte normalisierte Cache ist zwar unglaublich leistungsfähig, hat aber auch große Auswirkungen auf die Leistung bei der (erneuten) Abfrage größerer Datensätze. Die Auswirkung auf die TTI (Time to Interactive) ist für uns signifikant, da die Verarbeitung von 50 MB abgefragter Daten nach der beendeten Anfrage weitere 10 Sekunden in Anspruch nahm, um die Daten zu normalisieren und zu cachen. Bei den meisten größeren Queries verzichteten wir daher auf das Caching, was dazu führte, dass wir die zwischengespeicherten Daten nicht partiell aktualisieren konnten, sondern stattdessen den gesamten Datensatz nach Mutationen erneut abrufen mussten.
Ein weiteres Problem, das keinen Schaden anrichtet aber beim Entwickeln irritiert, ist die Notwendigkeit, beim Unmount von React-Komponenten manuell die Komponente eines eventuell noch laufenden Apollo-Queries zu entkoppeln (unsubscribe). Wenn ein Apollo Query Hook in einer React Komponente verwendet wird, benutzt Apollo intern den React.useState
-Hook. Jedes Mal, wenn der Query erneut ausgeführt wird, ruft Apollo setState
auf, um die Komponente über die aktualisierten Daten zu informieren, was die Komponente dazu veranlasst, den Query erneut auszuführen. Der verwendete GraphQL-Codegen versteckt im generierten Code leider die API, die ein Unsubscribe nach dem Unmount der Komponente ermöglichen würde.
Koppelt man eine Komponente ab, bevor alle Queries abgeschlossen sind, führt das zur Fehlermeldung Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application
in der React-Entwicklungsmodus-Konsole, beispielsweise wenn man von einem Screen oder einem Modal wegnavigiert, bevor alle laufenden GraphQL-Requests abgeschlossen sind.
Erwähnenswert ist außerdem, dass wir den Apollo Client lange Zeit in mehreren Projekten noch in der Version 2.* eingesetzt haben. Die aktuelle Major Version 3.* (Stand 04/2022) bringt umfangreiche Änderungen mit sich. Da ein Update des Pakets ohnehin fällig war, haben wir uns dazu entschieden, React Query auszuprobieren.
React Query
Im Jahr 2020 wurde die Version 1.0.0 von React Query veröffentlicht. Obwohl sie viel neuer ist als der Apollo-Client (Version 1.0.0 des Apollo-Clients wurde vor etwa 5 Jahren veröffentlicht), hatte diese Bibliothek genug Zeit, aus Kinderkrankheiten herauszuwachsen. React Query bietet eine schlanke Abstraktion für die Logik des Datenabrufs (fetching, caching, Synchronisation und Aktualisierung von Zustand auf dem Server) innerhalb von React-Komponenten. React Query selbst definiert nicht, wie und woher die Daten geladen werden und ist daher nicht an GraphQL gebunden, sondern funktioniert mit jeder asynchronen API. Dies ermöglicht es uns Entwickler:innen, jede Art Query innerhalb von React Query auszuführen und trotzdem eine einheitliche API für das Abrufen von Daten zu haben. Dazu gehören auch beispielsweise komplexerer Abfragelogik wie Polling, unendliche bzw. paginierte Queries und Caching basierend auf hierarchischen Schlüsseln. Auf der eigenen Website gibt das React Query Team einen guten Überblick über die Bibliothek und einen Vergleich mit Alternativen wie dem Apollo Client. Der GraphQL Code Generator bietet auch ein Plugin für React Query in TypeScript-Projekten.
Migration der Infrastruktur von Apollo Client zu React Query
Wir wollten React Query ausprobieren und haben uns entschlossen, eines der kleineren Projekte von Apollo Client (Version 2.*) auf React Query zu migrieren. Im folgenden Abschnitt werden die daraus resultierenden Änderungen im Setup gezeigt.
package.json
Für die Migration mussten nur das neue Codegen-Plugin und react-query installiert werden, um das alte Plugin und alle Dependencies zu Apollo zu ersetzen. Hier sind die Änderungen von der alten zur neuen package.json
(es werden nur Dependencies und Skripte angezeigt, die für Codegen und den GraphQL-Client relevant sind):
{
"devDependencies": {
"@graphql-codegen/cli": "^1.11.2",
"@graphql-codegen/fragment-matcher": "^2.0.1",
"@graphql-codegen/typescript": "^1.5.0",
"@graphql-codegen/typescript-graphql-files-modules": "^1.5.0",
"@graphql-codegen/typescript-operations": "^1.5.0",
"@graphql-codegen/typescript-react-apollo": "1"
// ...
},
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link": "^1.2.13",
"apollo-link-error": "^1.1.12",
"apollo-link-http": "^1.5.16",
"apollo-link-retry": "^2.2.15"
// ...
},
"scripts": {
"frontend:codegen": "graphql-codegen --config ./codegen.frontend.yml"
// ...
},
"scripts-info": {
"frontend:codegen": "run the codegen"
// ...
}
}
Und die neue Datei:
{
"devDependencies": {
"@graphql-codegen/cli": "^1.11.2",
"@graphql-codegen/fragment-matcher": "^2.0.1",
"@graphql-codegen/typescript": "^1.5.0",
"@graphql-codegen/typescript-graphql-files-modules": "^1.5.0",
"@graphql-codegen/typescript-operations": "^1.5.0",
"@graphql-codegen/typescript-react-query": "^3.5.9"
// ...
},
"dependencies": {
"react-query": "^3.34.19"
// ...
},
"scripts": {
"frontend:codegen": "graphql-codegen --config ./codegen.frontend.yml"
// ...
},
"scripts-info": {
"frontend:codegen": "run the codegen"
// ...
}
}
codegen.frontend.yml
Die codegen.frontend.yml
wird zur Konfiguration des GraphQL Code Generators verwendet.
overwrite: true
schema: "./src/schema.graphql"
documents: ./src/frontend/**/*.graphql
generates:
./src/frontend/common/graphql/generated/graphql-generated.ts:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"
config:
scalars:
GUID: string
Date: string
typesPrefix: "GraphQL"
withHOC: false
withComponent: false
noNamespaces: true
withHooks: true
hooksImportFrom: "@apollo/react-hooks"
withMutationFn: false
maybeValue: T | null | undefined
./src/frontend/common/graphql/generated/fragmentTypes.json:
plugins:
- fragment-matcher
Um von Apollo zu React Query zu migrieren, musste nur das Plugin ersetzt und die Konfiguration an das neue Plugin angepasst werden. Das React Query Plugin erlaubt mehrere Möglichkeiten, auf die GraphQL API zuzugreifen. Wir haben uns dafür entschieden, einen eigens entwickelten Fetcher zu schreiben, um unsere eigene Fehlerbehandlung und Logging einzubinden. Alternativ könnte man auch den GraphQL-Endpunkt direkt angeben (entweder in der YML-Datei oder über eine Umgebung), sodass der Fetcher automatisch generiert wird.
overwrite: true
schema: "./src/schema.graphql"
documents: ./src/frontend/**/*.graphql
generates:
./src/frontend/common/graphql/generated/graphql-generated.ts:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-query"
config:
exposeQueryKeys: true
# fetcher path is relative to the generated output
# codegen will automatically import and use the specified function like
# > import { fetchData } from '../queryClient';
fetcher: "../queryClient#fetchData"
maybeValue: T | null | undefined
scalars:
GUID: string
Date: string
typesPrefix: "GraphQL"
./src/frontend/common/graphql/generated/fragmentTypes.json:
plugins:
- fragment-matcher
Query client und Fetcher: apolloClient.ts und queryClient.ts
Das Setup des Apollo-Client ermöglicht die Angabe mehrerer Middlewares. Wir haben das für die Fehlerbehandlung, das Timing der Anfrage und die Ansteuerung des Endpunkts durch apollo-link genutzt. Da Apollo an die Verwendung von GraphQL gebunden ist, bietet die API für die onError-Middleware direkten Zugriff auf GraphQL-Fehler.
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloClient } from "apollo-client";
import { ApolloLink } from "apollo-link";
import { onError } from "apollo-link-error";
import { HttpLink } from "apollo-link-http";
export const apolloClient = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, response, operation, forward }) => {
// Error handling
// ...
}),
// Multiple middle- and afterwares using apollo-link
new ApolloLink((operation, forward) => {
// Actions before request
// (start request timer, notify global loading indicator, etc.)
// ...
return forward!(operation).map((result) => {
// Actions after request
// (stop request timer, notify global loading indicator, etc.)
// ...
return result;
});
}),
// ...
new HttpLink({
uri: "/api/graphql",
credentials: "same-origin",
}),
]),
cache: new InMemoryCache({ dataIdFromObject }),
});
function dataIdFromObject(entity: any): string | null {
// Custom logic to derive a unique key from an entity for the normalized cache
// ...
}
Der Query-Client für React Query benötigt deutlich weniger Setup. Das Standardverhalten ist modifizierbar und in Anbetracht unseres Einsatzgebiets haben wir uns entschieden, die Option refetchOnWindowFocus
zu deaktivieren. Da wir es von Apollo gewohnt sind, den Client und die Abfrage- und Fehlerbehandlungslogik an der gleichen Stelle zu haben, haben wir unsere benutzerdefinierte fetchData
-Funktion in dieselbe Datei eingefügt (wir haben das Anwendungsbeispiel aus der Dokumentation als Vorlage verwendet).
import { QueryClient } from "react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});
export const fetchData = <TData, TVariables>(
query: string,
variables?: TVariables,
options?: RequestInit["headers"]
): (() => Promise<TData>) => {
return async () => {
// Actions before request
// (start request timer, notify global loading indicator, etc.)
// ...
const res = await fetch("/api/graphql", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(options || {})
},
body: JSON.stringify({
query,
variables
})
})
.catch(err => {
// Network error handling
// ...
})
.finally(() => {
// Actions after request
// (stop request timer, notify global loading indicator, etc.)
// ...
});
const json = await res.json();
if (json.errors) {
// GraphQl Error handling
// ...
}
return json.data;
};
};
Globaler Query-Client-Provider in der App.tsx
Im globalen Einstiegspunkt App.tsx
haben wir den ApolloProvider
ersetzt.
import { ApolloProvider } from "@apollo/react-hooks";
import { apolloClient } from "./apolloClient";
// ...
export function App() {
return (
{/* Other Wrappers */}
{/* ... */}
<ApolloProvider client={apolloClient}>
{/* Content */}
{/* ... */}
</ApolloProvider>
);
}
mit dem jeweiligen QueryClientProvider
import { QueryClientProvider } from "react-query";
import { queryClient } from "./common/graphql/queryClient";
// ...
export function App() {
return (
{/* Other Wrappers */}
{/* ... */}
<QueryClientProvider client={queryClient}>
{/* Content */}
{/* ... */}
</QueryClientProvider>
);
}
Migration der eigentlichen Abfragen
Queries
GraphQL-Queries werden in .graphql
-Dateien definiert und der Code-Generator übernimmt automatisch die Erstellung der entsprechenden React-Hooks und Typescript-Typen. Die Migration von Apollo zu React Query für GraphQL-Queries wird am folgenden Beispiel ArticleSummaryArticle.graphql
gezeigt:
query ArticleSummaryArticle($articleNumber: String!) {
article(articleNumber: $articleNumber) {
number
description
# ...
}
}
Wir verwenden den automatisch generierten React-Hook für Apollo wie folgt:
import { useArticleSummaryArticleQuery } from "./graphql-generated";
// Within the scope of a React component:
const { data, error, refetch } = useArticleSummaryArticleQuery({
variables: {
articleNumber: props.articleNumber,
},
});
Für React Query besteht hier der einzige Unterschied darin, dass Variablen und Query Options nicht als Objektwerte, sondern als separate Argumente übergeben werden.
import { useArticleSummaryArticleQuery } from "./graphql-generated";
// Within the scope of a React component:
const { data, error, refetch } = useArticleSummaryArticleQuery({
articleNumber: props.articleNumber,
});
Bei generierten Query-Hooks können wir auch auf eine Funktion zugreifen, die den hierarchischen Schlüssel für die Abfrage generiert, da wir dies in der codegen.frontend.yml
mit der Option exposeQueryKeys: true
aktiviert haben. Dies wird für Cache-Operationen wichtig sein. Der entsprechende automatisch generierte Code sieht wie folgt aus:
useArticleSummaryArticleQuery.getKey = (
variables: GraphQLArticleSummaryArticleQueryVariables
) => ["ArticleSummaryArticle", variables];
Mutationen
Die API der React-Hooks für Mutationen, die die verschiedenen Codegen-Plugins generieren, hat sich etwas mehr verändert. Da Apollo sich im Grunde genommen automatisch um die Aktualisierung des normalisierten Caches gekümmert hat, müssen wir nun explizit entweder bestimmte Queries refetchen oder den Cache manuell aktualisieren. Wie bei den Queries generiert das jeweilige Codegen-Plugin für beide Bibliotheken React-Hooks, die in React-Komponenten verwendet werden können.
Mit der folgenden Beispiel-Mutation
mutation updateArticle($input: UpdateArticleInput!) {
updateArticle(input: $input) {
number
description
# ...
}
}
kann der entsprechende Apollo-Hook wie folgt verwendet werden:
import {
ArticleSummaryArticleDocument,
useUpdateArticleMutation
} from "./graphql-generated";
// Within the scope of a React component:
const [updateArticle] = useUpdateArticleMutation();
const handleSubmit = async () => {
const mutationResult = await updateArticle({
variables: {
input: {
// ...
}
},
// Refetching after the successful mutation can be triggered easily
// by adding the respective document to be refetched.
refetchQueries: [{
query: ArticleSummaryArticleDocument
variables: {
// ...
}
}]
})
}
Das Refetching ist im vorherigen Beispiel nur zur Veranschaulichung der API enthalten, da in allen Fällen des Projekts aufgrund der automatischen Aktualisierung des normalisierten Cache durch Apollo kein Refetching erforderlich war.
Neben kleineren Änderungen an der API des generierten Hooks musste eine explizite Logik zur Aktualisierung des Cachesnach erfolgreicher Mutation implementiert werden. Das erste Beispiel zeigt die gängigste Art der Aktualisierung veralteter Daten durch einfaches Refetching bestimmter Abfragen:
import { useQueryClient } from "react-query";
import {
useArticleSummaryArticleQuery,
useUpdateArticleMutation,
} from "./graphql-generated";
// Within the scope of a React component:
const queryClient = useQueryClient();
const updateArticleMutation = useUpdateArticleMutation();
const handleSubmit = async () => {
const mutationResult = await updateArticleMutation.mutateAsync(
{
input: {
// ...
},
},
{
onSuccess: async (updatedArticle) => {
/**
* The generated function useArticleSummaryArticleQuery.getKey(variables)
* returns ["ArticleSummaryArticle", variables];
*/
const articleSummaryQueryKey = useArticleSummaryArticleQuery.getKey({
articleNumber: updatedArticle.number,
});
/**
* Any currently running queries with that key need to be canceled
* as they were triggered before the mutation
*/
await queryClient.cancelQueries(queryKey);
// Then refetching can be triggered
return queryClient.refetchQueries(queryKey);
},
}
);
};
Die andere, etwas komplexere Möglichkeit besteht darin, bestimmte Einträge im Cache manuell zu aktualisieren, ohne die Daten erneut abzurufen. Hier wird dies anhand einer größeren Abfrage gezeigt, zu der der aktualisierte Artikel gehören soll:
import { useQueryClient } from "react-query";
import {
GraphQLArticlesListArticlesQuery,
useUpdateArticleMutation,
} from "./graphql-generated";
// Within the scope of a React component:
const queryClient = useQueryClient();
const updateArticleMutation = useUpdateArticleMutation();
const handleSubmit = async () => {
const mutationResult = await updateArticleMutation.mutateAsync(
{
input: {
// ...
},
},
{
onSuccess: (updatedArticle) => {
/**
* The generated function getKey(variables) returns the array:
* ["ArticlesListArticles", variables].
* If a query has no variables, the array always contains 1 element.
* This allows React Query cache to hierarchically store results from
* the same query with the same key hierarchy "ArticlesListArticles"
* but depending on the variables as different cache entries.
* All queries of the hierarchy can be accessed with the partial
* query key "ArticlesListArticles".
*/
const queryKey = useArticleGridArticlesQuery.getKey()[0] as string;
/**
* Only cached queries need to be updated. If there are no variables
* to the query, this will return only a single cached query
*/
const cachedQueries =
queryClient.getQueriesData<GraphQLArticlesListArticlesQuery>(
queryKey
);
/**
* Cancel potentially pending queries
* (results would clobber the manual update)
*/
await queryClient.cancelQueries([queryKey]);
// Each cached query needs to be updated.
for (const [queryKey, data] of cachedQueries) {
// Ensure that the cache includes data for the respective key
if (!data) continue;
/**
* Manually update the cached data. Cached data is a list of
* articles. Only the updated article needs to updated in the
* list. All other articles are just passed through.
*/
queryClient.setQueryData<GraphQLArticleGridArticlesQuery>(
queryKey,
(cachedData) =>
(cachedData ?? data).articles.map((cachedArticle) =>
cachedArticle.number === updatedArticle.number
? { ...cachedArticle, ...updatedArticle }
: cachedArticle
)
);
}
},
}
);
};
Vorgehen bei der Migration des ganzen Projektes
Bei größeren Projekten ist es kaum möglich, auf einem Schlag alles zu migrieren. Deshalb haben wir uns dazu entschieden, React Query zunächst parallel zum laufenden Apollo einzurichten. So konnten wir jeden Query und jede Mutation einzeln migrieren und gleichzeitig blockweise das aktualisierte Verhalten für jede logische Gruppierung von Query- und Mutationsoperationen validieren. Das sind die Schritte, die wir durchegführt haben:
-
Hinzufügen neuer Dependencies zu React Query und dem Codegen-Plugin.
-
Hinzufügen der Code Generation für React Query. Umbenennen des Apollo generator output Files (und Update aller Referenzen im Projekt auf das umbenannte generierte File) in der Codegen-Konfiguration
codegen.frontend.yml
.overwrite: true schema: "./src/schema.graphql" documents: ./src/frontend/**/*.graphql generates: ./src/frontend/common/graphql/generated/graphql-generated-apollo.ts: plugins: - "typescript" - "typescript-operations" - "typescript-react-apollo" config: # Apollo config # ... ./src/frontend/common/graphql/generated/graphql-generated.ts: plugins: - "typescript" - "typescript-operations" - "typescript-react-query" config: # React query config # ... ./src/frontend/common/graphql/generated/fragmentTypes.json: plugins: - fragment-matcher
-
Hinzufügen der neuen Datei
queryClient.ts
mit derfetchData
-Implementierung. -
Hinzufügen des
QueryClientProvider
zu unserem App-WrapperApp.tsx
, ohne denApolloProvider
zu entfernen, damit alles weiterläuft. -
Schrittweise Aktualisierung von Queries und Mutationen, mit Validierung des Verhaltens nach jedem Schritt.
-
Entfernen von Apollo aus
codegen.frontend.yml
-
Entfernen von Apollo Infrastruktur:
ApolloProvider
inApp.tsx
und den jetzt obsoletenapolloClient.ts
. -
Entfernen aller Dependencies auf Apollo.
Einschränkungen und Hinweise zu React Query
Eine Einschränkung, auf die wir bisher gestoßen sind, ist, dass das Codegen das Potenzial des hierarchischen Caches von React Query dämpft. Die hierarchischen Query-Keys könnten verwendet werden, um Anwendungs- und Komponenten-Scopes abzubilden, was einfache Scope-bezogene Cache-Invalidierung ermöglichen würde. Der Codegen hält sich leider an die Konvention, immer den Namen der Operation als ersten Teil des Abfrageschlüssels und das vollständige Variablenobjekt als zweiten Teil des Schlüssels zu verwenden.
Wir sind aber auf ein weiteres kleines Problem mit dem automatischen Refetching bei Refocus in Kombination mit manuellen Cache-Aktualisierungen nach einer erfolgreichen Mutation gestoßen. Wenn das Refetching mehr Zeit als die Mutation und die anschließende manuelle Cache-Aktualisierung in Anspruch nimmt (Szenario: Aktualisierung eines Artikels mit manueller Aktualisierung der Artikelliste im Cache und automatisches Refetching der gesamten Artikelliste on Focus), werden die automatisch gerefetchten Daten alle manuellen Cache-Aktualisierungen überschreiben (da React Query nicht wissen kann, dass die Daten in Beziehung stehen). Dies kann zu unerwartetem Verhalten führen. Eine Möglichkeit besteht darin, manuell sicherzustellen, dass alle zusammenhängenden ausstehenden Queries abgebrochen (und neu ausgelöst?) werden, wenn der Cache nach Mutationen manuell aktualisiert wird. Die andere Möglichkeit besteht darin, das automatische Refetching on Focus zu deaktivieren (diesen Weg haben wir gewählt).
Abschließende Gedanken
Nachdem wir eines unserer kleineren Projekte von Apollo zu React Query migriert hatten, waren wir überrascht, wie reibungslos der Übergang verlief. Mit dem Codegenerator als weitere Abstraktionsebene mussten bei der Migration von Queries nur kleine Änderungen an den APIs der generierten Hooks berücksichtigt werden. Die Möglichkeit, beide GraphQL-Clients parallel laufen zu lassen, half dabei, die schwierigere Aufgabe der Migration von Mutationen zu lösen, bei der nicht nur die kleinen Änderungen an der API, sondern auch die Unterschiede in der Herangehensweise des Cachings Änderungen in unserem Code erforderten.