Migrating Apollo GraphQL to React Query

Especially when it comes to larger data queries, you run into some hurdles with the Apollo client, which is why we decided to make a technology switch. In this case study we want to document the considerations and the process of replacing our GraphQL client Apollo with React Query in one of our projects.

Why Apollo GraphQL in the first place

Over the last years the free Apollo GraphQL has been our go-to GraphQL client in most of our React Web projects. We adopted GraphQL in an early stage of the eco system and Apollo was one of the few options providing a good abstraction for the client side of the communication via GraphQL. Aside of handling the REST communication with the GraphQL endpoint Apollo offers a sophisticated solution for result caching. Having a deeply connected graph with a relatively large dataset required to be available on the client side (> 50k interconnected entities across the graph) the normalized caching provided by Apollo seemed to solve the emerging problems. One of those problems was updating the respective data after running data mutations. The normalized Apollo cache automatically takes care of updating the respective cache entry (and thus notifying all subscribed components), as long as the mutation result includes the updated entities. It is easy to apply updates to single updated entities both optimistically and retroactively after a successful mutation while also keeping the queries that include the updated entity somewhere deeper up-to-date (imagine the entity project being included in a query for a list of projects). Apollo also provides a simple API to explicitly refetch specific queries after successful mutations. Any updates to the normalized cache will automatically trigger rerenders of React components depending on the requeried data. The seemless integration within the GraphQL Code Generator made working with the Apollo Client a breeze as fully typed React Hooks can be generated on the fly from a specified GraphQL schema and the used queries and mutations within the project.

Issues we encountered with the Apollo Client

While being incredibly powerful the normalized cache provided by Apollo also has a big impact on performance when (re-)querying bigger datasets. The hit on TTI (Time to Interactive) is significant for us as processing 50MB of queried data added another 10 seconds of processing after the finished request to normalize and cache the data. For most bigger queries we thus opted out of the caching, resulting in the inability to partially update cached data and instead needing to requery the full dataset after mutations. Another issue that poses more of an annoyance when developing than causing real harm is the necessity of manually unsubscribing when unmounting components. When using an Apollo query hook in a React component, Apollo internally uses the React.useState hook. Whenever the query is rerun Apollo calls setState to notify the component about the updated data causing the component to requery. When using the GraphQL codegen the generated code hides the API that would allow for unsubscribing after unmounting the component. This causes the react warning 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 to popup in the console while in React development mode whenever a component is unmounted before all queries are completed e.g. when navigating away from a screen or modal before all running graphql requests have finished.

Also noteworthy is the fact that having used the Apollo Client for a long time we were still using version 2.* of the package in several projects. The current major version 3.* introduces breaking changes, so as updating the package was due anyway, we decided to check out React Query.

React Query

Around 2020 version 1.0.0 of React Query was released. While being much newer than the Apollo Client (version 1.0.0 of the apollo-client was released about 5 years ago) the library has had its time to mature out of childhood problems. React Query provides a light-weight abstraction for data-fetching logic (fetching, caching, synchronizing and updating server state) within React components. React Query itself does not include the actual data fetching so it is not bound to be used with GraphQL but works with any asynchronous API. This allows developers to run any kind of request within React Query and still have a unified API for data-fetching including more complex fetching logic like polling, infinite or paginated queries and caching based on hierarchical keys. On their website the React Query team gives a good overview comparing the library with other libraries, including the Apollo Client. The GraphQL Code Generator also provides a plugin for React Query in TypeScript projects.

Migrating the infrastructure from Apollo Client to React Query

We wanted to give React Query a try and decided to migrate one of the smaller projects from Apollo Client (version 2.*) to React Query. In the following section the resulting changes in the setup are shown.

package.json

For the migration only the new codegen plugin and react-query needed to be installed to replace the old plugin and all apollo dependencies. See the changes from the old to the new package.json (only dependencies and scripts relevant to the codegen and the graphql client are shown):

{
  "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"
    // ...
  }
}

and the new file:

{
  "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

The codegen.frontend.yml is used to configure the GraphQL Code Generator.

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

To migrate from Apollo to React query only the plugin had to be replaced and the config adjusted to work with the new plugin. The React Query plugin allows multiple way to access your GraphQL API. We chose to write our own custom fetcher to be able to include our own error handling and logging. The other option would have been to directly specify the GraphQL endpoint (either in the YML file or through an environment) and have the fetcher be auto-generated.

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 and fetcher: apolloClient.ts and queryClient.ts

The setup of the Apollo Client allows to specify multiple middlewares. We used this for error handling, timing the request and specifying the endpoint through apollo-link. As Apollo is bound to be used with GraphQL, the API for the onError-middleware gives direct access to graphql errors.

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
  // ...
}

Being much more light-weight the query client for React Query needs much less setup. The default behaviour can be modified here and for reasons of how our application is meant to be used we decided to opt out of the option to refetchOnWindowFocus. Coming from Apollo we are used to have the client and the fetching and error handling logic in the same place, so we added our custom fetchData function to the same file (we used the usage example from the documentation as template)

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;
  };
};

Global query client provider in App.tsx

In our global entry point App.tsx we just replaced the ApolloProvider

import { ApolloProvider } from "@apollo/react-hooks";
import { apolloClient } from "./apolloClient";
// ...

export function App() {
  return (
    {/* Other Wrappers */}
    {/* ... */}
    <ApolloProvider client={apolloClient}>
      {/* Content */}
      {/* ... */}
    </ApolloProvider>
  );
}

with the respective QueryClientProvider

import { QueryClientProvider } from "react-query";
import { queryClient } from "./common/graphql/queryClient";
// ...

export function App() {
  return (
    {/* Other Wrappers */}
    {/* ... */}
    <QueryClientProvider client={queryClient}>
      {/* Content */}
      {/* ... */}
    </QueryClientProvider>
  );
}

Migrating the actual queries

Queries

GraphQL queries are defined in .graphql files and the codegen automatically handles creation of the respective hooks and types. The migration from Apollo to React Query for GraphQL queries will be shown with the example of the following sample query ArticleSummaryArticle.graphql:

query ArticleSummaryArticle($articleNumber: String!) {
  article(articleNumber: $articleNumber) {
    number
    description
    # ...
  }
}

We consume the auto-generated React hook for Apollo like this:

import { useArticleSummaryArticleQuery } from "./graphql-generated";

// Within the scope of a React component:
const { data, error, refetch } = useArticleSummaryArticleQuery({
  variables: {
    articleNumber: props.articleNumber,
  },
});

For React Query here the only difference is that variables and query options are not passed as object values but as separate arguments.

import { useArticleSummaryArticleQuery } from "./graphql-generated";

// Within the scope of a React component:
const { data, error, refetch } = useArticleSummaryArticleQuery({
  articleNumber: props.articleNumber,
});

On generated query-hooks we can also access a function that generates the hierarchical key for the query because we enabled that in the codegen.frontend.yml with the option exposeQueryKeys: true. This will be important for cache operations. The corresponding auto-generated code looks like this:

useArticleSummaryArticleQuery.getKey = (
  variables: GraphQLArticleSummaryArticleQueryVariables
) => ["ArticleSummaryArticle", variables];

Mutations

The API of React hooks for mutations generated the different codegen plugins changed a bit more. Additionally as Apollo basically took care of updating the normalized cache automatically, we now need to explicitly either refetch certain queries or update the cache manually. As with queries, for both libraries the respective codegen plugin generates React hooks that can be used in React components.

With the following sample mutation

mutation updateArticle($input: UpdateArticleInput!) {
  updateArticle(input: $input) {
    number
    description
    # ...
  }
}

the according Apollo hook can be used like this:

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: {
          // ...
        }
      }]
    })
  }

Refetching is only included in the previous example to show the API as in all cases in the project no refetching was needed due to automatic updates in the normalized cache through Apollo.

Besides minor changes to the API of the generated hook, explicit cache updating logic after successful mutation needed to be implemented. The first example shows the most common way of updating stale data by just refetching certain queries:

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);
      },
    }
  );
};

The other a bit more complex way is to manually update certain entries in the cache without refetching data. Here this is shown using a bigger query in which the updated article is expected to be part of:

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
              )
          );
        }
      },
    }
  );
};

Workflow of the actual migration

For bigger projects it is not feasible to completely break the project until everything is migrated. We thus chose to migrate the project by setting up React Query while keeping Apollo running. This allowed us to migrate every query and mutation one by one while being able to validate the updated behavior for each logical group of query and mutation operations. The steps we took were:

  1. Add new dependencies to React Query and the codegen plugin.
  2. Add code generation for React Query. Rename output file of the Apollo generator (and to keep the project running update the according import in all files) in 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
  1. Add the new queryClient.ts file with the fetchData implementation.
  2. Add the QueryClientProvider to our app wrapper App.tsx without removing the ApolloProvider to keep things running
  3. Update queries and mutations one by one while being able to build and validate the intended behavior after each update
  4. Remove Apollo from codegen.frontend.yml
  5. Remove Apollo infrastructure code: ApolloProvider in App.tsx and remove obsolete file apolloClient.ts
  6. Remove all dependencies to Apollo

Caveats with React Query

One caveat we encountered so far is that the codegen hinders the potential of the hierarchy cache provided by React Query. The hierarchical query keys could be used to mirror application and component scopes allowing quick access to scope-wide cache invalidation. The codegen sadly sticks to the convention to always have the operation name as first part of the query key and the full variables object as the second part of the key.

We encountered another small issue with automatic refetching on refocus in combination with running manual cache updates after a successful mutation. If refetching takes longer than the mutation and the following manual cache update (scenario: update an article with manually update the articles list in the cache and automatic refetch the whole list of articles on focus) the automatically refetched data will clobber any manual cache updates (as React Query cannot know that the data is related). This can lead to unexpected behavior. One option is to manually ensure to cancel (and retrigger?) all related pending queries when manually updating the cache after mutations. The other option is to opt out of the automatic refetch on focus.

Concluding Thoughts

Having migrated one of our smaller projects from Apollo to React Query we were surprised how smooth the transition was. With the code generator as another layer of abstraction only small changes to the APIs of the generated hooks needed to be considered when migrating queries. Being able to have both GraphQL clients work in conjunction helped to solve the more difficult task of migrating mutations where not only the small changes to the API but also the differences in the approach to caching required changes in our code.