Mobile offline

React Query persistence.

8 minmedium

Mobile users open the app on the subway. The bookmark list from this morning should still be there. React Query has a persister plugin that makes this nearly free β€” 20 lines of setup, and your cached data survives a cold start.

The pattern

React Query persistence loop

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” persist on β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ React Query β”‚ ───── change ───► β”‚ AsyncStorage β”‚ β”‚ cache β”‚ β”‚ (mobile disk) β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ app cold start β”‚ │◄──── restore β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό render with cached data immediately, then trigger a background refetch
The cache is mirrored to AsyncStorage. App restart restores from disk before re-fetching.

Setup β€” one file, ~20 lines

apps/mobile/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h β€” must outlive persistence window
staleTime: 1000 * 60 * 5, // 5m fresh
retry: 2,
},
},
})
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'app-cache',
})
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24h
})

Wire it into the app root

apps/mobile/app/_layout.tsx (snippet)
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '../lib/query-client'
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
)
}

That's it. Every query is now persisted. Restart the app while offline β€” your bookmark list is there, the product list is there, login state survives.

What gets persisted

By default: everything. To exclude sensitive data:

persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24,
dehydrateOptions: {
shouldDehydrateQuery: (q) => {
// Don't persist the /me query β€” auth state shouldn't outlive a session
if (q.queryKey[0] === 'me') return false
return true
},
},
})

Rule of thumb: persist lists, search results, and reference data. Do NOT persist payment info, tokens, or anything sensitive β€” that's what SecureStore is for.

The first-paint trick

When the app boots offline, React Query renders the cached data instantly. When the network comes back, it auto-refetches β€” silently. The user sees the "old" bookmarks first, which update if anything changed. No spinner, no jank.

Stale data is fine; broken data is not. Persistence shows what you last saw. If a bookmark was deleted server-side while you were offline, you'll see it briefly when the app opens, then it disappears on refetch. That's fine. What you DON'T want is to take a destructive action on data you've cached but the server no longer has β€” check for 404 on every mutation.

Offline writes β€” separate problem

React Query persistence only handles READS. If the user bookmarks something offline, the mutation will fail at the network call. For now: optimistic update + a toast ("will retry when online") is good enough.

For real offline writes, you'd wire React Query's onlineManager + a mutation pause queue. Most teams skip this on mobile (toast + retry on manual user action is fine) and put the heavy queueing on desktop, where users expect it (next lesson).

Showing offline state

apps/mobile/components/OfflineBanner.tsx
import NetInfo from '@react-native-community/netinfo'
import { useEffect, useState } from 'react'
import { View, Text } from 'react-native'
export function OfflineBanner() {
const [online, setOnline] = useState(true)
useEffect(() => {
const unsub = NetInfo.addEventListener((s) => setOnline(!!s.isConnected))
return unsub
}, [])
if (online) return null
return (
<View className="bg-amber-500/10 border-b border-amber-500/30 px-4 py-2">
<Text className="text-amber-500 text-xs">
Offline β€” showing your last cached data
</Text>
</View>
)
}

Show this above the tab bar. It tells the user why they might see slightly old data, and removes the "is this broken?" anxiety.

Quick check

A user opens the app on a flight (offline). They had 5 bookmarks the last time they were online. What do they see?

Try it

Make your bookmarks survive a flight:

  1. Add the persistence setup above.
  2. Bookmark 3 products.
  3. Force-quit the app, enable airplane mode.
  4. Reopen β€” bookmarks should be there instantly.
  5. Add the OfflineBanner. Confirm it shows in airplane mode and disappears when you reconnect.
  6. Disable airplane mode β€” confirm a silent refetch happens.

What's next

Next lesson β€” Desktop outbox. Desktop users expect long-running, queue-and-flush writes. We'll wire a proper outbox pattern in SQLite.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub