Use suspense with apollo today
As someone who cares a lot about syntax and code cleanliness, I’m extremely impatient for apollo to support suspense. Rather than switch clients entirely and give up on normalized caching and custom middleware, I decided to wrap apollo client in react-query. Though react-query is far from a heavy dependency, bringing in a whole query framework to support suspense might seem like overkill. This is just a temporary solution until apollo has first-party support for suspense.
There are other articles out there that show you how to write your own suspense wrapper around apollo, but I trust react-query more. Also, these articles don’t usually cover caching. We’ll cover a little bit of caching here.
The idea is to stop using apollo’s hooks and instead create a react-query hook that wraps ApolloClient.query()
Step 1: Create the wrapper hook
// useSuspensedQuery.ts
import { DocumentNode } from "graphql";
import { useQuery, UseQueryResult } from "react-query";
import { useApolloClient } from '@apollo/client';
export function useSuspensedQuery<Query, Variables>(
query: DocumentNode,
variables: Variables
): UseQueryResult<Query>;
export function useSuspensedQuery<Query>(
query: DocumentNode
): UseQueryResult<Query>;
export function useSuspensedQuery<Query, Variables>(
query: DocumentNode,
variables?: Variables
): UseQueryResult<Query> {
const client = useApolloClient()
const key = query.loc.source.body + JSON.stringify(variables);
return useQuery(
key,
async () => {
const resp = await client.query({ query, variables });
return resp.data;
},
{ retry: false }
);
}
The only complicated part is really the static typing. I used typescript’s function overloading to support queries with and without variables. If you’re not using typescript, you can simplify this down to just the implementation:
import { useQuery } from "react-query";
import { useApolloClient } from '@apollo/client';
export function useSuspensedQuery(query, variables){
const client = useApolloClient();
const key = query.loc.source.body + JSON.stringify(variables);
return useQuery(
key,
async () => {
const resp = await client.query({ query, variables });
return resp.data;
},
{ retry: false }
);
}
Step 2: Provide react-query dependencies
Before we use this, react-query useQuery
requires a context provider component that provides access to a react-query client. You’ll want to mount the provider in a similar place as your ApolloProvider
.
// reactQueryClient.ts
import { QueryClient as ReactQueryClient } from "react-query";
export const reactQueryClient = new ReactQueryClient({
defaultOptions: {
queries: { suspense: true },
},
});
// App.tsx
import { ApolloProvider } from "@apollo/client";
import { QueryClientProvider } from "react-query";
import { reactQueryClient } from "reactQueryClient";
import { apolloClient } from "apolloClient";
// ...
export default function App() {
const lang = window.lang;
return (
<ApolloProvider client={apolloClient}>
<QueryClientProvider client={reactQueryClient}>
//...
</QueryClientProvider>
</ApolloProvider>
);
}
Step 3: Use the hook!
Like apollo’s own useQuery, our hook expects a graphql document as its primary argument.
//MyComponent.tsx
import { useSuspensedQuery } from "utils";
import { PostsDocument, PostsQuery, PostsQueryVariables } from "./Posts.gql";
export function PostsPage({ authorId }:{ authorId: string }){
const { data } = useSuspensedQuery<PostsQuery, PostsQueryVariables>(
PostsDocument,
{ authorId }
);
return (
<div>
{data.posts.map(post => /* ... */ )}
</div>
)
}
I generate document variables, as well as types for the variables and the response using graphql-codegen’s apollo integrations. If you’re not using typescript, you can simplify this down to just the implementation and write your queries inline:
import { useSuspensedQuery } from "utils";
import gql from "graphql-tag";
const PostsDocument = gql`
query Posts($authorId: ID!) {
posts(authorId: $authorId) {
# ...
}
}
`;
export function PostsPage({ authorId }){
const { data } = useSuspensedQuery( PostsDocument, { authorId });
// ...
}
Step 4: Fix caching issues
react-query and apollo both have their own caching solutions. Our hooks use a query’s graphql string and stringified variables as a cache key. However, if you mutate data using a mutation, the cache key will return stale data. I solve this problem by sub-classing ApolloClient forcing apollo to clear the reactQueryClient’s cache whenever a mutation is called.
A custom apollo link might be more idiomatic ¯\(ツ)/¯
//apolloClient.ts
class ReactQueryCacheClearingApolloClient extends ApolloClient {
mutate(...args) {
reactQueryClient.invalidateQueries();
return super.mutate.apply(this, args);
}
}
export const client = new ReactQueryCacheClearingApolloClient({
// ... your cache and links arguments
});
The implicit assumption here is that we trust data until we make a mutation or the window is refreshed. If you have more complex caching requirements, you’ll likely want something different.
Next steps?
Lighter alternatives
If you’re looking for a more light weight alternative to importing react-query but don’t want to write your own suspense implementation, you can give https://github.com/pmndrs/suspend-react a try. I use this on a read-only project where the client never calls any mutations, so I trust the cache to never get stale. That being said, suspend-react has all the necessary hooks to implement complex own caching logic.
Another benefit to suspend-react is that you can create resources as plain functions instead of hooks. This means you can bypass the rules of react-hooks.
Supporting parallel queries
Since react-query’s useQuery
will prevent sequential queries from running in parallel (at least in suspense mode), this can create dreaded waterfall requests. There are two obvious ways around this constraint:
By using react-query useQueries
React Query provides a hook to execute multiple queries in parallel. This is great for re-usability. The only challenge is static typing. Don’t believe me? Someone tried and it’s still not perfect.
The ideal API might look something like:
import { useQueries } from "react-query";
import { PostsDocument, PostsQuery } from "./Posts.gql";
import { ProfileDocument, ProfileQuery } from "./Profile.gql";
const [ postsData, profileData] = useSuspensedQueries<
PostsQuery, PostsQueryVariables, ProfileQuery, ProfileQueryVariables
>(
PostsDocument,
{ authorId: "1" },
ProfileDocument,
{ profileID: "2" }
);
By creating intermediary hooks
Combine promises outputted from apolloClient.query(...)
using Promise.all
and wrap this combination in useQuery
. This involves more boilerplate.
import { useQuery } from "react-query";
import { useApolloClient } from '@apollo/client';
import { PostsDocument, PostsQuery } from "./Posts.gql";
import { ProfileDocument, ProfileQuery } from "./Profile.gql";
export function usePostsAndProfileData({authorId, profileId}:{authorId: string, profileId: string}){
const client = useApolloClient();
//remember, this cache-key is just for react-query. Apollo will still cache the 2 queries independently.
const cacheKey = `PostsQuery-${authorId}-ProfileQuery-${profileId}`;
return useQuery(
cacheKey,
async () => {
const [profileData, postsData] = await Promise.all([
client.query({ query: ProfileDocument, variables: {profileId} }),
client.query({ query: PostsDocument, variables: {authorId} }),
]);
return [profileData.data as ProfileQuery, postsData.data as PostsQuery];
},
{ retry: false }
);
}
Error handling
I’ve been using this formula without any issues, but you may have different error and retry logic.