ApolloProvider, InMemoryCache, optimistic UI, and offline-first
Apollo Client connects GraphQL and React Native through a normalizing cache, typed hooks, and a persistence layer that keeps data available across app restarts. Configuring ApolloClient, InMemoryCache, and the Apollo hooks correctly means fewer network roundtrips, instant UI feedback through optimistic updates, and an app that stays usable even on flaky mobile connections.
Table of Contents
- 1. Why GraphQL and Apollo Client fit React Native
- 2. ApolloClient and ApolloProvider: the basic setup
- 3. InMemoryCache: normalization and configuration
- 4. Loading data with useQuery: loading, error, data
- 5. useLazyQuery: triggering queries on demand
- 6. Mutations with useMutation: optimistic UI and cache updates
- 7. Error handling: network errors vs. GraphQL errors
- 8. Pagination with fetchMore
- 9. Offline-first on mobile and Apollo Client compared
- 10. Summary
- 11. FAQ
1. Why GraphQL and Apollo Client fit React Native
React Native apps rarely run under stable network conditions. Switching between Wi-Fi and cellular, subway tunnels, elevators, and international roaming all produce fluctuating latency and occasional connection drops. This is exactly the environment where GraphQL shows its advantage over classic REST: instead of querying three or four separate REST endpoints one after another for a nested view, say a user profile with posts and comments, a single GraphQL query returns exactly the fields needed in one round trip. On a mobile network with 150 to 400 milliseconds of latency per request, that is the difference between a view that feels instant and one that visibly loads in stages.
Apollo Client is the library that makes these GraphQL requests practically usable for React Native. It does not stop at simply issuing queries, it brings a normalizing cache, deduplicated in-flight requests, and React hooks that feel like native function-component patterns. Anyone who has ever manually wired fetch calls together with useState and useEffect for loading and error states quickly notices how much of that code becomes unnecessary with useQuery and useMutation.
Another reason lies in the typed schema. GraphQL schemas are a binding contract between backend and app, and codegen tools generate TypeScript types from that schema for every query and mutation. A typo in a field name becomes visible at compile time instead of showing up as broken UI in production. For React Native, where App Store reviews and OTA rollouts can slow down release cycles, catching errors this early is especially valuable, because a hotfix cannot go live within minutes the way it can for a web app.
2. ApolloClient and ApolloProvider: the basic setup
Getting started with Apollo Client begins with three building blocks: an HttpLink that knows the GraphQL endpoint URL, an InMemoryCache instance, and the ApolloClient constructor that ties both together. The ApolloProvider component then wraps the app root and makes the client available to every component below it through React context, without having to pass it down as a prop through every layer. In a typical Expo app that happens right at the top of App.tsx, before the navigation container.
A single HttpLink is rarely enough for authentication. The common approach is a link chain built with ApolloLink.from, where an authLink uses setContext to read the access token from secure storage and attach it as an authorization header before the request reaches the httpLink. This chain can be extended as needed, for example with an errorLink for centralized logging or a retryLink for automatic retries on transient network errors.
It is worth setting up cache persistence with apollo3-cache-persist right in this setup step. A CachePersistor is created with the InMemoryCache instance and an AsyncStorage wrapper, and its restore method must be awaited before the first render. In Expo this pairs well with SplashScreen.preventAutoHideAsync, so the app does not briefly show an empty cache state before the persisted data has loaded.
# Install Apollo Client, GraphQL, and cache persistence for React Native
npm install @apollo/client graphql
npm install apollo3-cache-persist @react-native-async-storage/async-storage
# Optional: NetInfo to detect connectivity changes for offline-aware links
npm install @react-native-community/netinfo
// apolloClient.js - ApolloClient + ApolloProvider setup with persisted cache
import { useEffect, useState } from 'react';
import {
ApolloClient,
ApolloProvider,
InMemoryCache,
HttpLink,
ApolloLink,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { CachePersistor } from 'apollo3-cache-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
});
// Auth middleware: attach the access token to every outgoing request
const authLink = setContext(async (_, { headers }) => {
const token = await SecureStore.getItemAsync('access_token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feedItems: {
keyArgs: false,
merge(existing = [], incoming, { args }) {
// Append rather than overwrite so fetchMore extends the list
const offset = args?.offset ?? 0;
const merged = existing.slice(0);
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
export function ApolloAppProvider({ children }) {
const [client, setClient] = useState(null);
useEffect(() => {
async function init() {
const persistor = new CachePersistor({
cache,
storage: AsyncStorage,
maxSize: false,
debug: __DEV__,
});
// Restore persisted cache before the app renders any query result
await persistor.restore();
setClient(
new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache,
connectToDevTools: __DEV__,
})
);
}
init();
}, []);
if (!client) {
return null; // keep native splash screen visible until cache is restored
}
return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
3. InMemoryCache: normalization and configuration
The central value of Apollo Client over a plain fetch wrapper lies in InMemoryCache and its normalization. Any object carrying a __typename field and a unique identifier field such as id or uid is not stored as a nested copy inside the response tree, but as a flat entry keyed by Typename:id. If two different screens query the same object, say a product in a list and that same product on a detail page, both show the same cache reference. When a mutation changes that object, both views update automatically without any manual re-fetch.
This normalization can be fine-tuned through typePolicies. Objects without a standard id field need their own keyFields definition, for example a combination of two fields as a composite key. For list fields that grow through pagination, a merge function defines how new data is combined with what already exists instead of simply overwriting it. Without such a merge function, every fetchMore call would replace the existing list with the new page instead of extending it.
On React Native, this normalized cache pays off twice. First, it reduces network traffic, which is directly noticeable given limited mobile data plans. Second, it enables instant UI rendering when navigating back between screens in the React Navigation stack, because useQuery shows the cached data first for the same query and variables, before deciding in the background via a refetch whether anything has changed.
// schema.graphql (excerpt) - id and __typename drive cache normalization
type Product {
id: ID!
name: String!
price: Float!
inStock: Boolean!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
products(first: Int!, after: String): ProductConnection!
}
4. Loading data with useQuery: loading, error, data
The useQuery hook is the most common entry point into Apollo Client and runs a GraphQL query automatically when the component mounts. It returns an object with loading, error, data, and helper functions such as refetch and fetchMore. The usual pattern in React Native components is an early return for the loading and error state, before the actual content renders with the loaded data. That keeps the JSX readable and avoids nested conditions deep in the tree.
The options passed to useQuery control fetchPolicy, skip, and variables. A fetchPolicy of cache-and-network shows cached data immediately but also triggers a network request in parallel, while cache-first only fires a request if nothing is in the cache yet. On React Native, pairing this with useFocusEffect from React Navigation makes sense: when a user comes back to a screen via a back gesture, a targeted refetch can refresh stale data without the query fully reloading on every plain tab switch.
For pull-to-refresh behavior, which users almost expect on mobile, the RefreshControl component from React Native combined with the refetch function from useQuery is enough. A local refreshing state controls the spinner display, while refetch runs the same query with the same variables again in the background and updates the cache once the response arrives.
// ProductListScreen.jsx - useQuery with explicit loading/error/data handling
import { FlatList, Text, View, RefreshControl } from 'react-native';
import { useQuery, gql } from '@apollo/client';
import { useState, useCallback } from 'react';
const PRODUCTS_QUERY = gql`
query Products($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
cursor
node {
id
name
price
inStock
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export function ProductListScreen() {
const [refreshing, setRefreshing] = useState(false);
const { loading, error, data, refetch } = useQuery(PRODUCTS_QUERY, {
variables: { first: 20, after: null },
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
});
const onRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
if (loading && !data) {
return <Text>Loading products...</Text>;
}
if (error) {
return <Text>Could not load products: {error.message}</Text>;
}
const products = data.products.edges.map((edge) => edge.node);
return (
<FlatList
data={products}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({ item }) => (
<View style={{ padding: 12 }}>
<Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
<Text>{item.inStock ? `$${item.price}` : 'Out of stock'}</Text>
</View>
)}
/>
);
}
5. useLazyQuery: triggering queries on demand
While useQuery fires a query immediately when the component mounts, useLazyQuery waits for an explicit trigger. The hook does not return a simple object but a tuple: a trigger function first, and a result object with loading, data, and error second. This pattern fits interactions where the timing of the request is determined by the user, not by the component rendering.
Typical use cases in React Native include a search field with debouncing that only sends a GraphQL query after a pause in typing of a few hundred milliseconds, or a barcode scanner screen that triggers a product query only after a successful scan. A login form also benefits from this: the query for fetching the profile should only run after successful authentication, not already when the form renders.
An important difference from useQuery concerns the cache: useLazyQuery uses the same normalized InMemoryCache and respects the same fetchPolicy, but the request is only triggered by the manual call of the trigger function. Repeated calls with the same variables hit the cache first, just like useQuery, before a new network request is decided on.
6. Mutations with useMutation: optimistic UI and cache updates
The useMutation hook runs write operations and returns a trigger function along with loading, error, and data states. The most important lever for perceived performance is the optimisticResponse option: instead of waiting for the server response, Apollo Client updates the cache immediately with the expected result, and the UI shows the change before a single byte comes back from the server. On mobile networks with 200 to 800 milliseconds of latency per request, that is the difference between an app that feels sluggish and one that feels natively fast.
For cases where plain normalization is not enough, for example inserting a new element into a list, the update function provides access to cache.modify. This lets you read an existing cache field and extend it with the new element without re-running the whole query. That is especially relevant for lists with pagination, where a plain refetch would be expensive and would destroy the user's scroll position.
If the mutation actually fails, Apollo Client automatically removes the optimistic layer and restores the previous cache state. The UI briefly shows the optimistic version but then corrects itself once the real error arrives. Application code should still handle that error case explicitly, for example with a toast informing the user of the failure, because a silent rollback without any hint feels confusing.
// AddToCartButton.jsx - useMutation with optimistic response and cache.modify
import { Pressable, Text } from 'react-native';
import { useMutation, gql } from '@apollo/client';
const ADD_TO_CART = gql`
mutation AddToCart($productId: ID!, $quantity: Int!) {
addToCart(productId: $productId, quantity: $quantity) {
id
quantity
product {
id
name
}
}
}
`;
export function AddToCartButton({ product }) {
const [addToCart, { loading }] = useMutation(ADD_TO_CART, {
variables: { productId: product.id, quantity: 1 },
// UI updates immediately, before the server responds
optimisticResponse: {
addToCart: {
__typename: 'CartItem',
id: `optimistic-${product.id}`,
quantity: 1,
product: {
__typename: 'Product',
id: product.id,
name: product.name,
},
},
},
update(cache, { data }) {
const newItem = data.addToCart;
cache.modify({
fields: {
cartItems(existingItems = []) {
const newItemRef = cache.writeFragment({
data: newItem,
fragment: gql`
fragment NewCartItem on CartItem {
id
quantity
product {
id
name
}
}
`,
});
return [...existingItems, newItemRef];
},
},
});
},
onError(error) {
// Apollo Client already rolled the optimistic layer back automatically
console.warn('Add to cart failed:', error.message);
},
});
return (
<Pressable onPress={() => addToCart()} disabled={loading}>
<Text>{loading ? 'Adding...' : 'Add to cart'}</Text>
</Pressable>
);
}
7. Error handling: network errors vs. GraphQL errors
An ApolloError object distinguishes two fundamentally different error categories, and that distinction is essential for a clean React Native UI. A networkError means the request never reached the server or never got a response, for example due to a missing connection, a DNS failure, or a timeout. A graphQLErrors array, on the other hand, means the server did respond but rejected the request on content grounds, for example due to missing permissions, a validation rule, or a record that was not found.
On mobile, the networkError case is especially common, because airplane mode, dead zones, and subway tunnels are part of daily life. A specific UX makes sense here, for example a subtle snackbar saying "No connection" instead of a generic error message that leaves the user unsure what happened. graphQLErrors, on the other hand, can often be handled more precisely: an error code such as AUTH_NOT_AUTHENTICATED in the extensions of a GraphQL error can automatically trigger a redirect to the login screen, while a validation error is shown directly on the affected form field.
Instead of wrapping every component individually in try-catch, a centralized errorLink in the Apollo link chain pays off. All errors can be forwarded there to a logging service like Sentry before they even reach a component. An additional retryLink can automatically retry transient network errors with exponential backoff, so that a brief connection drop in a subway tunnel never even shows up as a visible error in the UI, because the second or third attempt already succeeds.
8. Pagination with fetchMore
The fetchMore function returned by useQuery loads additional data with changed variables, such as a new after cursor, and combines the result with the already loaded data through the merge function defined in the cache. In current versions of Apollo Client, this is the recommended approach, while the previously used updateQuery option is now considered deprecated in favor of the centralized merge logic in typePolicies.
For the structure of the pagination itself, the cursor-based Relay connection pattern with edges, node, pageInfo, hasNextPage, and endCursor, already set up in the schema example from section 3, is recommended. Cursor-based pagination is more robust than plain offset-based pagination because it stays consistent even when the underlying list changes between two requests, for example because an element was deleted.
In the UI, an onEndReached callback from FlatList triggers the next fetchMore call as the user approaches the end of the currently loaded list. A guard against duplicate calls is important: without checking the current loading state, fast scrolling can trigger multiple fetchMore calls at once and produce unnecessary duplicate requests. A simple call looks like this: fetchMore(variables: after data.products.pageInfo.endCursor), combined with a footer loading indicator while the next page is loading.
9. Offline-first on mobile and Apollo Client compared
Mobile reality means a network connection can drop at any moment, while an app simultaneously moves between foreground and background whenever the user switches to another app or locks the device. Apollo Client is not a complete offline queue system out of the box, but the combination of cache persistence, clean error handling, and AppState handling covers most practical cases in a React Native app.
The CachePersistor shown earlier ensures that cached data from AsyncStorage is available immediately at app start, even before a single network request has been answered. On top of that, it is worth adding an AppState listener that calls client.reFetchObservableQueries when the app switches to the active state, so stale data gets refreshed after a longer period in the background. Combined with @react-native-community/netinfo, a link can also be configured to hold back mutations while offline instead of letting them fail immediately.
Compared with other approaches to GraphQL and data access on mobile, it becomes clear why Apollo Client is often the first choice for more complex apps, while lighter alternatives can be sufficient for simpler requirements.
| Approach | Cache normalization | Bundle size | Recommendation for React Native |
|---|---|---|---|
| Apollo Client | Full, normalized by __typename+id | Larger, but a complete ecosystem | Complex apps with many linked entities |
| urql | Optional, via the Graphcache extension | Notably smaller than Apollo Client | Smaller apps where full normalization is not needed |
| REST + fetch | No built-in cache | Minimal, no extra library | Only sensible for very simple apps with few endpoints |
| TanStack Query | Query-key based, no GraphQL object normalization | Small, data-source agnostic | Great for REST, weaker for GraphQL without normalization |
10. Summary
The combination of GraphQL and Apollo Client solves a very concrete problem for React Native apps: loading nested data in a single round trip instead of querying several REST endpoints one after another. ApolloClient and ApolloProvider form the basic scaffolding, InMemoryCache normalizes objects by __typename and id, and apollo3-cache-persist keeps that cache available in AsyncStorage across app restarts. useQuery, useLazyQuery, and useMutation cover the three most common interaction patterns: automatic loading, user-triggered loading, and write operations with instant UI feedback through optimistic responses.
For a robust mobile experience, the details matter too: a clean separation between networkError and graphQLErrors for the right error messages, cursor-based pagination with fetchMore for growing lists, and an AppState listener that refreshes stale data when returning from the background. Anyone who consistently assembles these building blocks gets a React Native app built on Apollo Client that feels reliable and fast even under fluctuating mobile connectivity.
React Native GraphQL Integration with Apollo Client, the essentials at a glance
Setup
ApolloClient with HttpLink and InMemoryCache, ApolloProvider wraps the app root, authLink attaches tokens via setContext on every request.
Cache and persistence
InMemoryCache normalizes by __typename+id, apollo3-cache-persist secures the cache in AsyncStorage across app restarts.
Hooks
useQuery loads automatically, useLazyQuery on a trigger, useMutation with optimisticResponse for instant UI feedback.
Mobile robustness
Cleanly separate networkError from graphQLErrors, use fetchMore for pagination, and an AppState listener to refetch after background phases.