React Native Offline Data with React Query and Zustand

How we handle offline data in a production React Native app using React Query for server state and Zustand for local caching. Practical patterns that work.

| 11 min read
React Native Offline Data with React Query and Zustand blog post header image

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.

Daniel Taylor's avatar

Daniel Taylor

Software Engineer

Recent case studies

Here's a look at some of products we've brought to market recently

Cyber Check - Cyber Essentials Compliance, Simplified

Cyber Check - Cyber Essentials Compliance, Simplified

A desktop application that automates Cyber Essentials Plus device auditing. Built from our own certification experience because manually checking 50 security settings across every machine is nobody's idea of a good time.

With Jack - Freelance Insurance

With Jack - Freelance Insurance

With Jack offers peace of mind and protection for UK freelance creatives and SMEs. Friendly, personable and reliable insurance.

Simple ASO Keyword Tool - Free ASO Platform

Simple ASO Keyword Tool - Free ASO Platform

We built a free, no-nonsense App Store Optimization tool that helps developers avoid common keyword mistakes and boost their app's visibility. What started as an afternoon project has evolved into a suite of free ASO tools helping app creators worldwide get their apps discovered.

We take products from an idea to revenue

Add Jam is your plug in team of web and mobile developers, designers and product managers. We work with you to create, ship and scale digital products that people use and love.

Hello, let's chat 👋
michael hayes avatar photo

Michael Hayes

Co-founder of Add Jam

Hey! Co-founder of Add Jam here. I'm available to chat about startups, tech, design, and development. Drop me a message or book a call in my calendar at a time that suits you.