Offline & Data· 5 min read

TanStack Query in React Native: Caching and Offline Persistence

Use TanStack Query in React Native properly: app focus and online state, persisted caches for instant startup, offline mutations and infinite feeds.

By

Full-Stack, Mobile & AI Engineer

TanStack Query (formerly React Query) is the most effective way to manage server data in React Native: caching, background refetching, retries, pagination and mutations with one consistent API. But it was designed in browsers first, and a few defaults assume a browser: "window focus" and "online" events don't exist on phones the same way.

This guide shows how to set up TanStack Query in React Native properly in 2026: connecting it to app focus and network state, persisting the cache for instant startup, handling mutations offline, and building infinite feeds.

Key takeaways

  • Wire focusManager to AppState so data refreshes when users return to the app.
  • Wire onlineManager to NetInfo so queries pause offline and resume on reconnect.
  • Persist the query cache (to AsyncStorage or MMKV) so the app renders the last known data instantly at launch.
  • Set staleTime deliberately per kind of data, and keep gcTime at least as long as your persistence window.
  • Use optimistic updates and paused mutations for snappy, offline-tolerant writes.

Setup

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // treat data as fresh for a minute
      gcTime: 24 * 60 * 60_000,   // keep unused data for a day (needed for persistence)
      retry: 2,
    },
  },
});

App focus: refetch when users come back

On the web, TanStack Query refetches stale data when the window regains focus. In React Native, connect it to AppState:

import { useEffect } from "react";
import { AppState, Platform, type AppStateStatus } from "react-native";
import { focusManager } from "@tanstack/react-query";

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== "web") focusManager.setFocused(status === "active");
}

export function useAppFocusRefetch() {
  useEffect(() => {
    const sub = AppState.addEventListener("change", onAppStateChange);
    return () => sub.remove();
  }, []);
}

Online state: pause offline, resume on reconnect

import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => setOnline(!!state.isConnected))
);

Queries now pause instead of failing when the device is offline, and resume automatically when it reconnects.

Persist the cache for instant startup

Without persistence, every cold start begins with empty data and a spinner. With it, the app renders yesterday's data immediately and refreshes in the background:

import AsyncStorage from "@react-native-async-storage/async-storage";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";

const persister = createAsyncStoragePersister({ storage: AsyncStorage });

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister, maxAge: 24 * 60 * 60_000 }}
    >
      {children}
    </PersistQueryClientProvider>
  );
}

MMKV is a faster storage backend for the same idea; use a synchronous persister with an MMKV adapter if startup speed is critical. Don't persist sensitive data (anything you wouldn't store unencrypted), and exclude it from persistence with dehydrateOptions if needed.

Choosing staleTime by data type

DataSuggested staleTimeWhy
Static content (categories, plans)HoursRarely changes
User profileMinutesChanges occasionally
Feeds, dashboardsSeconds to a minuteUsers expect freshness on return
Live data (prices, availability)0, plus polling or socketsMust be current

Mutations: optimistic and offline-tolerant

For actions like "book a class" or "like a post", update the UI immediately and roll back if the server rejects it:

const queryClient = useQueryClient();

const bookClass = useMutation({
  mutationFn: (classId: string) => api.post(`/classes/${classId}/book`),
  onMutate: async (classId) => {
    await queryClient.cancelQueries({ queryKey: ["classes"] });
    const previous = queryClient.getQueryData<ClassItem[]>(["classes"]);
    queryClient.setQueryData<ClassItem[]>(["classes"], (old = []) =>
      old.map((c) => (c.id === classId ? { ...c, booked: true } : c))
    );
    return { previous };
  },
  onError: (_err, _classId, ctx) => queryClient.setQueryData(["classes"], ctx?.previous),
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["classes"] }),
});

When offline, mutations pause rather than fail, and persisted paused mutations can resume after a restart with queryClient.resumePausedMutations() (set default mutation functions so they can be restored). For writes that must never be lost, such as workout sessions or field reports, a local database with an outbox is the safer choice; TanStack Query works well on top of it.

From the field: Cache-first fetching was one of three levers, with Hermes and image optimisation, that cut load time by about 60% (3.8 seconds to 1.5) in apps I migrated to the New Architecture. Rendering from cache first and refreshing quietly is the single biggest difference users feel on a slow network.

Query keys that scale

Query keys decide caching and invalidation, so treat them as an API. A small key factory per feature keeps them consistent:

export const classKeys = {
  all: ["classes"] as const,
  list: (filters: ClassFilters) => [...classKeys.all, "list", filters] as const,
  detail: (id: string) => [...classKeys.all, "detail", id] as const,
};

Invalidating classKeys.all then refreshes every list and detail for classes, while invalidating one detail key leaves the rest alone. Include every variable a query depends on (filters, user, language) in its key, or different screens will share data they shouldn't.

Common mistakes

  • Leaving staleTime at zero everywhere, so every mount and focus refetches, wasting battery and data.
  • Copying server data into global state (Zustand, Redux) and then keeping two copies in sync. Let TanStack Query own server data; keep only client state elsewhere.
  • Fetching in useEffect alongside queries, which brings back the loading and race-condition bugs the library exists to remove.
  • Persisting everything, including sensitive or huge responses. Choose what to persist.
  • Forgetting the React Native setup for app focus and online state, then wondering why data never refreshes after returning to the app.

When TanStack Query isn't enough on its own

  • Real-time data: keep using queries, but update the cache from WebSocket events with setQueryData, so screens stay live without polling.
  • User-created data that must survive offline: store it in a local database with an outbox, and let queries read from your sync layer.
  • Large local datasets you need to search and sort: that's a database's job (SQLite), not a request cache's.

Testing screens that use queries

Data-fetching screens are easy to test once you control the network and the cache:

  • Create a fresh QueryClient per test, with retries turned off, so tests don't leak cached data into each other or wait for retry delays.
  • Mock at the network boundary, not inside components. Mocking your API module, or using a request-mocking library such as MSW, keeps components and hooks running their real code.
  • Test every state the user can see: loading, success, empty, error and offline. The empty and error states are the ones most often broken in production.
  • Test optimistic updates by making the mocked request fail and checking that the UI rolls back.

A handful of these tests on your core screens catches most data bugs before release.

Infinite feeds

useInfiniteQuery handles paginated feeds and pairs naturally with FlashList:

const feed = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam }) => api.get(`/feed?cursor=${pageParam ?? ""}`),
  initialPageParam: null as string | null,
  getNextPageParam: (last) => last.nextCursor ?? undefined,
});
// <FlashList data={feed.data?.pages.flatMap((p) => p.items)} onEndReached={() => feed.fetchNextPage()} />

Use cursor-based pagination on the server rather than page numbers, so new items don't shift pages while the user scrolls.

FAQ

Does TanStack Query work with React Native?

Yes. It works the same as on the web, with two additions: connect focusManager to React Native's AppState so data refreshes when the app returns to the foreground, and connect onlineManager to NetInfo so queries pause offline and resume on reconnect.

How do I persist React Query data in React Native?

Use PersistQueryClientProvider with a persister backed by AsyncStorage (or MMKV for speed), and set gcTime at least as long as your persistence maxAge. The app then restores cached data at launch and refreshes it in the background.

Can TanStack Query handle offline mutations?

Yes, to a point. Mutations pause while offline and resume when the connection returns, and persisted paused mutations can resume after a restart. For data that must never be lost, such as user-created records, combine it with a local database and an outbox.

What staleTime should I use?

It depends on the data: hours for static content, minutes for profiles, seconds to a minute for feeds and dashboards, and zero with polling or sockets for live data. The default of zero refetches very often, which is rarely what mobile apps need.

Next steps

If your app shows spinners on every launch or fetches the same data repeatedly, I can set up caching and persistence so it feels instant on any network. Get in touch, or see how I work as a React Native developer.

Sources

Bhuwanesh Sisodia

Bhuwanesh Sisodia

Full-Stack, Mobile & AI Engineer

Bhuwanesh Sisodia is a Full-Stack, Mobile & AI Engineer based in Mumbai, with 7+ years of experience and 50+ apps shipped to the App Store and Google Play. He works independently with startups and product teams worldwide.

About meGitHubLinkedInX

Related