Mobile apps don't have the luxury of assuming a stable internet connection. Users open your app on the train, in underground car parks, in rural areas with patchy signal. If your app shows a blank screen or a spinner every time the network drops out you're going to lose people fast.
This is something we've had to solve on a recent React Native project. The app is data heavy. It fetches large datasets from an API, displays them on a map and lets users interact with individual items. Users rely on it in areas with inconsistent connectivity so we needed it to feel fast and usable even when the network wasn't cooperating.
Here's how we approached it using two libraries that complement each other well: React Query (now TanStack Query) for server state management and Zustand for local client-side caching.
Why two libraries?
This is a fair question. React Query and Zustand solve different problems and trying to force one library to do both jobs usually ends in a mess.
React Query handles server state. It manages fetching, caching, background refetching and synchronisation with your API. It's brilliant at knowing when data is stale, when to refetch and how to keep your UI in sync with the server. If you're still using useEffect and useState to fetch data you really should look at React Query. It removes a huge amount of boilerplate and handles edge cases you probably haven't thought of.
Zustand handles client state. It's a lightweight state management library for React. Think of it as a simpler alternative to Redux. No boilerplate, no providers wrapping your entire app, no action creators. You create a store, put state in it and access it from any component. We use it for things like user preferences, UI state and in this case as a local cache layer for data that needs to persist across navigations and survive network interruptions.
React Query knows how to talk to your server. Zustand knows how to hold onto data locally. Together they give you a solid foundation for offline-capable apps.
Persisting React Query's cache to disk
Out of the box React Query caches data in memory. That's great while the app is running but the moment a user closes and reopens it everything is gone. They're back to loading spinners.
React Query has an official plugin for this called persistQueryClient. It lets you serialise the query cache to storage so it survives app restarts. We hooked it up to AsyncStorage:
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 queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 4 * 60 * 60 * 1000, // 4 hours }, }, }) const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, key: "REACT_QUERY_OFFLINE_CACHE", throttleTime: 1000, })
Then wrap your app with the persist provider instead of the standard QueryClientProvider:
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: asyncStoragePersister, maxAge: 24 * 60 * 60 * 1000, }} > {/* your app */} </PersistQueryClientProvider>
Now when a user opens the app they see their last known data immediately while React Query quietly refetches in the background. No loading screens, no blank states. The app feels instant.
Be selective about what you persist
You don't want to persist everything. Large datasets will bloat AsyncStorage and slow down both reads and writes. We used the shouldDehydrateQuery option to exclude heavy queries:
dehydrateOptions: { shouldDehydrateQuery: (query) => { const [queryKeyRoot] = query.queryKey as [string?, ...unknown[]] // Don't persist large paginated datasets if (queryKeyRoot === "paginated-items" || queryKeyRoot === "items") { return false } // Only persist successful queries that have data return query.state.status === "success" && query.state.data != null }, }
Things like user profile data, settings and small reference datasets are good candidates for persistence. Large lists with thousands of items are not. For those we use Zustand.
Using Zustand as a local data cache
For the large datasets that React Query shouldn't persist we built a dedicated cache layer in Zustand. The idea is simple. As data comes in from the API we write it to a Zustand store keyed by ID. Components can then read from this store even if the network is unavailable.
Here's a simplified version of the cache slice:
import { StateCreator } from "zustand" type ItemsCacheSlice = { itemsData: Record<string, Item> lastFetchedAt: number | null addItems: (items: Item[]) => void clearCache: () => void getItemsArray: () => Item[] getItemsByRegion: (region: BoundingBox) => Item[] } export const createItemsCacheSlice: StateCreator< StoreSlices, [["zustand/persist", unknown]], [], ItemsCacheSlice > = (set, get) => ({ itemsData: {}, lastFetchedAt: null, addItems: items => set(state => { if (items.length === 0) return state const updated = { ...state.itemsData } items.forEach(item => { updated[item.id] = item }) return { itemsData: updated, lastFetchedAt: Date.now() } }), clearCache: () => set(() => ({ itemsData: {}, lastFetchedAt: null })), getItemsArray: () => Object.values(get().itemsData), getItemsByRegion: region => { const items = Object.values(get().itemsData) return items.filter(item => isWithinBoundingBox(item.coordinates, region)) }, })
A few things worth noting here. We store items in a Record<string, Item> (essentially a plain object keyed by ID) rather than an array. This makes lookups and deduplication trivial. We actually have two methods for writing to the cache. addItems merges new data into the existing cache which is useful for smaller region-based fetches where you want data to accumulate as users explore. For full dataset refreshes we use a separate replaceItems method that swaps the entire cache in one go. This avoids stale data lingering when the server dataset has changed.
The getItemsByRegion method lets us serve cached data based on what's visible on screen without hitting the API. If the user pans to an area they've already visited we show cached results immediately.
Connecting the two layers
The real magic happens when React Query and Zustand work together. Here's the pattern we settled on.
Writing to the cache on fetch
When React Query fetches data successfully we write it into the Zustand store:
const addItems = useStore(state => state.addItems) const { data, isLoading, refetch } = useQuery({ queryKey: ["items", filtersKey], queryFn: () => fetchItems(filters), select: response => response.data?.items ?? [], staleTime: 1000 * 60 * 5, refetchInterval: 1000 * 120, }) const lastPushedAtRef = useRef<number>(0) useEffect(() => { if (!query.isSuccess) return if (query.dataUpdatedAt === lastPushedAtRef.current) return lastPushedAtRef.current = query.dataUpdatedAt addItems(data || []) }, [data, query.isSuccess, query.dataUpdatedAt, addItems])
Reading from the cache as placeholder data
While React Query is fetching fresh data we can serve cached results from Zustand as placeholder data. This means the UI always has something to show:
const getItemsByRegion = useStore(state => state.getItemsByRegion) const placeholderData = useMemo(() => { if (filters?.boundingBox) { const cached = getItemsByRegion(filters.boundingBox) if (cached.length > 0) { return { data: { items: cached } } } } return { data: { items: [] } } }, [filters?.boundingBox, getItemsByRegion])
The user experience is noticeably better. Instead of showing a loading spinner while data loads the app shows whatever it already knows about and then silently swaps in fresh data when it arrives.
Fetching paginated data without hammering your store
If you're fetching paginated data across many pages you don't want to write to the Zustand store after every single page. Each write triggers re-renders in any component subscribed to that slice of state. With 20 pages of results that's 20 re-renders in quick succession.
We collect all page results into a local array first and write to the store once at the end:
const BATCH_SIZE = 3 const allItems: Item[] = [] const batches: number[][] = [] for (let i = 0; i < remainingPages; i += BATCH_SIZE) { batches.push( Array.from( { length: Math.min(BATCH_SIZE, remainingPages - i) }, (_, idx) => i + idx + 2 ) ) } for (const batch of batches) { for (const page of batch) { const response = await fetchPage(page, signal) if (response.data?.items) { allItems.push(...response.data.items) } } // Yield control between batches so the UI stays responsive await new Promise(resolve => setTimeout(resolve, 0)) } // Single store write with the complete dataset replaceItems(allItems)
The batching here isn't about writing to the store per batch. It's about chunking the network requests so you can yield control back to the event loop between batches. This keeps the UI thread responsive while a potentially large paginated fetch runs in the background. The store only gets written to once with the full dataset which means one re-render instead of twenty.
Smart refetching with debounced regions
For a map-based app you don't want to refetch data every time the user pans slightly. We use two separate debounced region trackers. One updates the visual region quickly (150ms) so clustering and pin display feels responsive. The other updates the query region more slowly (500ms) which is what triggers API fetches.
const debouncedSetQueryRegion = useMemo( () => debounce((region: Region) => { setQueryRegion(region) }, 500), [setQueryRegion] )
When the query region updates we convert it to a bounding box and expand it by a factor (we use 1.6x when a bottom sheet is open to account for the obscured area). This expanded bounding box gets passed as a filter to the API:
const regionToBoundingBox = useCallback((region: Region | null) => { if (!region) return undefined return { southwestLat: region.latitude - region.latitudeDelta / 2, southwestLng: region.longitude - region.longitudeDelta / 2, northeastLat: region.latitude + region.latitudeDelta / 2, northeastLng: region.longitude + region.longitudeDelta / 2, } }, [])
Meanwhile the Zustand cache serves data for the visible region immediately. If the user pans to an area they've already visited the cached data appears instantly while the debounced fetch catches up in the background. The 500ms debounce means rapid panning doesn't fire off a dozen API calls. It waits for the user to settle.
Handling app state changes
React Native apps frequently move between foreground and background. By default React Query doesn't know about these transitions so it won't refetch when a user returns to the app after being away.
We added an AppState listener to keep React Query's focus state in sync:
import { AppState } from "react-native" import { focusManager } from "@tanstack/react-query" useEffect(() => { const subscription = AppState.addEventListener("change", status => { focusManager.setFocused(status === "active") }) return () => subscription.remove() }, [])
This means when a user switches back to the app React Query checks its stale times and refetches anything that needs updating. The cache keeps the UI populated while fresh data loads in the background.
What we learned
A few takeaways from building this out:
Separate your concerns. React Query for server state, Zustand for client cache. Trying to do both with one tool leads to awkward workarounds and code that's hard to reason about.
Cache selectively. Not everything should be persisted. Large datasets belong in a purpose-built cache layer not in AsyncStorage via React Query's persist plugin.
Batch your writes. If you're writing to state frequently batch the updates. Your users will notice the difference especially on Android.
Show something immediately. The fastest-feeling app is one that always has data to show. Use cached data as placeholder content while fresh data loads. Users don't mind seeing slightly stale data if the alternative is a spinner.
Think about what "offline" means for your users. For our app it wasn't about full offline-first capability. It was about gracefully handling the moments when connectivity is spotty. Tunnels, underground car parks, rural areas. The cache bridges those gaps.
Building offline-capable React Native apps isn't as daunting as it sounds. With React Query and Zustand doing the heavy lifting the actual implementation is straightforward. The hard part is deciding what to cache, when to refetch and how to keep everything in sync. Get those decisions right and your app will feel fast and reliable regardless of the network.
If you're building a React Native app and want to improve how it handles data and offline scenarios we'd love to hear about your project. Get in touch or book a free 30-minute consultation with the Add Jam team. We've been shipping production React Native apps for over 10 years and this is exactly the kind of problem we enjoy solving.





