Subchapter 1.11
references/suspense-hooks.mdMarkdown22 KBView on GitHub
Note: Suspense hooks are the recommended approach for data fetching in modern React applications (React 18+). They provide cleaner code, better loading state handling, and enable streaming SSR.
The useSuspenseQuery hook is the Suspense-ready replacement for useQuery. It initiates a network request and causes the component calling it to suspend while the request is made. Unlike useQuery, it does not return loading states—these are handled by React’s Suspense boundaries and error boundaries.
import { Suspense } from "react";
import { useSuspenseQuery } from "@apollo/client/react";
import { GET_DOG } from "./queries.generated";
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dog id="3" />
</Suspense>
);
}
function Dog({ id }: { id: string }) {
const { data } = useSuspenseQuery(GET_DOG, {
variables: { id },
});
// data is always defined when this component renders
return <div>Name: {data.dog.name}</div>;
}const {
data, // Query result data
dataState, // With default options: "complete" | "streaming"
// With returnPartialData: also "partial"
// With errorPolicy "all" or "ignore": also "empty"
error, // ApolloError (only when errorPolicy is "all" or "ignore")
networkStatus, // NetworkStatus.ready, NetworkStatus.loading, etc.
client, // Apollo Client instance
refetch, // Function to re-execute query
fetchMore, // Function for pagination
} = useSuspenseQuery(QUERY, options);loading boolean: Component suspends instead of returning loading: trueerrorPolicy (none), errors are thrown and caught by error boundaries. With errorPolicy: "all" or "ignore", the error property is returned and data may be undefined.data availability: With default errorPolicy (none), data is guaranteed to be present when the component renders. With errorPolicy: "all" or "ignore", when dataState is empty, data may be undefined.<Suspense> to handle loading stateWhen variables change, useSuspenseQuery automatically re-runs the query. If the data is not in the cache, the component suspends again.
import { useState } from "react";
import { GET_DOGS } from "./queries.generated";
function DogSelector() {
const { data } = useSuspenseQuery(GET_DOGS);
const [selectedDog, setSelectedDog] = useState(data.dogs[0].id);
return (
<>
<select
value={selectedDog}
onChange={(e) => setSelectedDog(e.target.value)}
>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.id}>
{dog.name}
</option>
))}
</select>
<Suspense fallback={<div>Loading...</div>}>
<Dog id={selectedDog} />
</Suspense>
</>
);
}
function Dog({ id }: { id: string }) {
const { data } = useSuspenseQuery(GET_DOG, {
variables: { id },
});
return (
<>
<div>Name: {data.dog.name}</div>
<div>Breed: {data.dog.breed}</div>
</>
);
}Use returnPartialData to render immediately with partial cache data instead of suspending. The component will still suspend if there is no data in the cache.
function Dog({ id }: { id: string }) {
const { data } = useSuspenseQuery(GET_DOG, {
variables: { id },
returnPartialData: true,
});
return (
<>
<div>Name: {data.dog?.name ?? "Unknown"}</div>
{data.dog?.breed && <div>Breed: {data.dog.breed}</div>}
</>
);
}Use useBackgroundQuery with useReadQuery to avoid request waterfalls by starting a query in a parent component and reading the result in a child component. This pattern enables the parent to start fetching data before the child component renders.
import { Suspense } from "react";
import { useBackgroundQuery, useReadQuery } from "@apollo/client/react";
function Parent() {
// Start fetching immediately
const [queryRef] = useBackgroundQuery(GET_DOG, {
variables: { id: "3" },
});
return (
<Suspense fallback={<div>Loading...</div>}>
<Child queryRef={queryRef} />
</Suspense>
);
}
function Child({ queryRef }: { queryRef: QueryRef<DogData> }) {
// Read the query result
const { data } = useReadQuery(queryRef);
return <div>Name: {data.dog.name}</div>;
}useBackgroundQuery returns a tuple:
const [
queryRef, // QueryRef to pass to useReadQuery
{ refetch, fetchMore, subscribeToMore }, // Helper functions
] = useBackgroundQuery(QUERY, options);useReadQuery returns the query result:
const {
data, // Query result data (always defined)
dataState, // "complete" | "streaming" | "partial" | "empty"
error, // ApolloError (if errorPolicy allows)
networkStatus, // Detailed network state (1-8)
} = useReadQuery(queryRef);Use useLoadableQuery to imperatively load a query in response to a user interaction (like a button click) instead of on component mount.
import { Suspense } from "react";
import { useLoadableQuery, useReadQuery } from "@apollo/client/react";
import { GET_GREETING } from "./queries.generated";
function App() {
const [loadGreeting, queryRef] = useLoadableQuery(GET_GREETING);
return (
<>
<button
onClick={() => loadGreeting({ variables: { language: "english" } })}
>
Load Greeting
</button>
<Suspense fallback={<div>Loading...</div>}>
{queryRef && <Greeting queryRef={queryRef} />}
</Suspense>
</>
);
}
function Greeting({ queryRef }: { queryRef: QueryRef<GreetingData> }) {
const { data } = useReadQuery(queryRef);
return <div>{data.greeting.message}</div>;
}const [
loadQuery, // Function to load the query
queryRef, // QueryRef (null until loadQuery is called)
{ refetch, fetchMore, subscribeToMore, reset }, // Helper functions
] = useLoadableQuery(QUERY, options);The createQueryPreloader function creates a preloadQuery function that can be used to initiate queries outside of React components. This is useful for preloading data before a component renders, such as in route loaders or event handlers.
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { createQueryPreloader } from "@apollo/client/react";
const client = new ApolloClient({
uri: "https://your-graphql-endpoint.com/graphql",
cache: new InMemoryCache(),
});
// Create a preload function
export const preloadQuery = createQueryPreloader(client);Note: This example applies to React Router in non-framework mode. For React Router framework mode, see integration-react-router.md.
Use the preload function with React Router’s loader function to begin loading data during route transitions:
import { preloadQuery } from "@/lib/apollo-client";
import { GET_DOG } from "./queries.generated";
// React Router loader function
export async function loader({ params }: { params: { id: string } }) {
return preloadQuery({
query: GET_DOG,
variables: { id: params.id },
});
}
// Route component
export default function DogRoute() {
const queryRef = useLoaderData();
return (
<Suspense fallback={<div>Loading...</div>}>
<DogDetails queryRef={queryRef} />
</Suspense>
);
}
function DogDetails({ queryRef }: { queryRef: QueryRef<DogData> }) {
const { data } = useReadQuery(queryRef);
return (
<div>
<h1>{data.dog.name}</h1>
<p>Breed: {data.dog.breed}</p>
</div>
);
}Use preloadQuery.toPromise(queryRef) to prevent route transitions until the query finishes loading:
export async function loader({ params }: { params: { id: string } }) {
const queryRef = preloadQuery({
query: GET_DOG,
variables: { id: params.id },
});
// Wait for the query to complete before transitioning
return preloadQuery.toPromise(queryRef);
}When preloadQuery.toPromise() is used, the route transition waits for the query to complete, and the data renders immediately without showing a loading fallback.
Note:
preloadQuery.toPromise()resolves with thequeryRefitself (not the data) to encourage usinguseReadQueryfor cache updates. If you need raw query data in your loader, useclient.query()directly.
Note: For Next.js App Router, use the
PreloadQuerycomponent from@apollo/client-integration-nextjsinstead. See integration-nextjs.md for details.
The useQueryRefHandlers hook provides access to refetch and fetchMore functions for queries initiated with preloadQuery, useBackgroundQuery, or useLoadableQuery. This is useful when you need to refetch or paginate data in components where the queryRef is passed through.
Important: Always call
useQueryRefHandlersbeforeuseReadQuery. These two hooks interact with the samequeryRef, and calling them in the wrong order could cause subtle bugs.
import { useQueryRefHandlers } from "@apollo/client/react";
function Breeds({ queryRef }: { queryRef: QueryRef<BreedsData> }) {
const { refetch } = useQueryRefHandlers(queryRef);
const { data } = useReadQuery(queryRef);
const [isPending, startTransition] = useTransition();
return (
<div>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
refetch();
});
}}
>
{isPending ? "Refetching..." : "Refetch breeds"}
</button>
<ul>
{data.breeds.map((breed) => (
<li key={breed.id}>{breed.name}</li>
))}
</ul>
</div>
);
}Use fetchMore to implement pagination:
function Posts({ queryRef }: { queryRef: QueryRef<PostsData> }) {
const { fetchMore } = useQueryRefHandlers(queryRef);
const { data } = useReadQuery(queryRef);
const [isPending, startTransition] = useTransition();
return (
<div>
<ul>
{data.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
fetchMore({
variables: {
offset: data.posts.length,
},
});
});
}}
>
{isPending ? "Loading..." : "Load more"}
</button>
</div>
);
}preloadQueryqueryRef from useBackgroundQueryuseLoadableQueryApollo Client uses the combination of query and variables to uniquely identify each query. When multiple components use the same query and variables, they share the same identity and suspend at the same time, regardless of which component initiates the request.
Use the queryKey option to ensure each hook has a unique identity:
function UserProfile() {
// First query with unique key
const { data: userData } = useSuspenseQuery(GET_USER, {
variables: { id: "1" },
queryKey: ["user-profile"],
});
// Second query with same query and variables but different key
const { data: userPreview } = useSuspenseQuery(GET_USER, {
variables: { id: "1" },
queryKey: ["user-preview"],
});
return (
<div>
<UserCard user={userData.user} />
<UserSidebar user={userPreview.user} />
</div>
);
}Note: Each item in the
queryKeyarray must be a stable identifier to prevent infinite fetches.
Wrap components that use Suspense hooks with <Suspense> boundaries to handle loading states. Place boundaries strategically to control the granularity of loading indicators.
function App() {
return (
<>
{/* Top-level loading for entire page */}
<Suspense fallback={<PageSpinner />}>
<Header />
<Content />
</Suspense>
</>
);
}
function Content() {
return (
<>
<MainSection />
{/* Granular loading for sidebar */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</>
);
}Suspense hooks throw errors to React error boundaries instead of returning them. Use error boundaries to handle GraphQL errors.
import { ErrorBoundary } from "react-error-boundary";
function App() {
return (
<ErrorBoundary
fallback={({ error }) => (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
)}
>
<Suspense fallback={<div>Loading...</div>}>
<Dog id="3" />
</Suspense>
</ErrorBoundary>
);
}Use errorPolicy to control how errors are handled:
function Dog({ id }: { id: string }) {
const { data, error } = useSuspenseQuery(GET_DOG, {
variables: { id },
errorPolicy: "all", // Return both data and errors
});
return (
<>
<div>Name: {data?.dog?.name ?? "Unknown"}</div>
{error && <div>Warning: {error.message}</div>}
</>
);
}Use React transitions to avoid showing loading UI when updating state. Transitions keep the previous UI visible while new data is fetching.
import { useState, Suspense, startTransition } from "react";
function DogSelector() {
const { data } = useSuspenseQuery(GET_DOGS);
const [selectedDog, setSelectedDog] = useState(data.dogs[0].id);
return (
<>
<select
value={selectedDog}
onChange={(e) => {
// Wrap state update in startTransition
startTransition(() => {
setSelectedDog(e.target.value);
});
}}
>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.id}>
{dog.name}
</option>
))}
</select>
<Suspense fallback={<div>Loading...</div>}>
<Dog id={selectedDog} />
</Suspense>
</>
);
}Use useTransition to get an isPending flag for visual feedback during transitions.
import { useState, Suspense, useTransition } from "react";
function DogSelector() {
const [isPending, startTransition] = useTransition();
const { data } = useSuspenseQuery(GET_DOGS);
const [selectedDog, setSelectedDog] = useState(data.dogs[0].id);
return (
<>
<select
style={{ opacity: isPending ? 0.5 : 1 }}
value={selectedDog}
onChange={(e) => {
startTransition(() => {
setSelectedDog(e.target.value);
});
}}
>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.id}>
{dog.name}
</option>
))}
</select>
<Suspense fallback={<div>Loading...</div>}>
<Dog id={selectedDog} />
</Suspense>
</>
);
}Request waterfalls occur when a child component waits for the parent to finish rendering before it can start fetching its own data. Use useBackgroundQuery to start fetching child data earlier in the component tree.
Note: When one query depends on the result of another query (e.g., the child query needs an ID from the parent query), the waterfall is unavoidable. The best solution is to restructure your schema to fetch all needed data in a single nested query.
When queries don’t depend on each other, use useBackgroundQuery to start them in parallel:
const GET_USER = gql`
query GetUser($id: String!) {
user(id: $id) {
id
name
}
}
`;
const GET_POSTS = gql`
query GetPosts {
posts {
id
title
}
}
`;
function Parent() {
// Both queries start immediately - no waterfall
const [userRef] = useBackgroundQuery(GET_USER, {
variables: { id: "1" },
});
const [postsRef] = useBackgroundQuery(GET_POSTS);
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile queryRef={userRef} />
<PostsList queryRef={postsRef} />
</Suspense>
);
}
function UserProfile({ queryRef }: { queryRef: QueryRef<UserData> }) {
const { data } = useReadQuery(queryRef);
return <div>User: {data.user.name}</div>;
}
function PostsList({ queryRef }: { queryRef: QueryRef<PostsData> }) {
const { data } = useReadQuery(queryRef);
return (
<ul>
{data.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Suspense hooks support most of the same fetch policies as useQuery, controlling how the query interacts with the cache. Note that cache-only and standby are not supported by Suspense hooks.
| Policy | Description |
|---|---|
cache-first | Return cached data if available, otherwise fetch (default) |
cache-and-network | Return cached data immediately, then fetch and update |
network-only | Always fetch, update cache, ignore cached data |
no-cache | Always fetch, never read or write cache |
// Always fetch fresh data
const { data } = useSuspenseQuery(GET_NOTIFICATIONS, {
fetchPolicy: "network-only",
});
// Prefer cached data
const { data } = useSuspenseQuery(GET_CATEGORIES, {
fetchPolicy: "cache-first",
});
// Show cached data while fetching fresh data
const { data } = useSuspenseQuery(GET_POSTS, {
fetchPolicy: "cache-and-network",
});Apollo Client integrates with modern React frameworks that support Streaming SSR and React Server Components. For detailed setup instructions specific to your framework, see:
These guides cover:
useBackgroundQuery and SuspenseUse skipToken to conditionally skip queries without TypeScript issues. When skipToken is used, the component won’t suspend and data will be undefined.
import { skipToken } from "@apollo/client";
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
function UserProfile({ userId }: { userId: string | null }) {
const { data, dataState } = useSuspenseQuery(
GET_USER,
!userId ? skipToken : (
{
variables: { id: userId },
}
)
);
if (dataState !== "complete") {
return <p>Select a user</p>;
}
return <Profile user={data.user} />;
}Alternatively, use conditional rendering to control when Suspense hooks are called. This provides better type safety and clearer component logic.
function UserProfile({ userId }: { userId: string | null }) {
if (!userId) {
return <p>Select a user</p>;
}
return (
<Suspense fallback={<div>Loading...</div>}>
<UserDetails userId={userId} />
</Suspense>
);
}
function UserDetails({ userId }: { userId: string }) {
const { data } = useSuspenseQuery(GET_USER, {
variables: { id: userId },
});
return <Profile user={data.user} />;
}Note: Using conditional rendering with
skipTokenprovides better type safety and avoids issues with required variables. Theskipoption is deprecated in favor ofskipToken.