Skip to main content
  1. Posts/

Zustand vs React Query vs AsyncStorage: What Goes Where

· loading · loading ·
Jared Lynskey
Author
Jared Lynskey
Emerging leader and software engineer based in Seoul, South Korea
Table of Contents

The curtain quoting app I’m building has one hard requirement: it has to keep working with no signal. Quotes get measured up on site visits, and site visits happen in new builds and basements where reception is a rumour. That constraint forced me to get honest about a question most React projects fudge: which kind of state lives where?

The usual failure mode is one store for everything. API caching, user preferences, form state, auth tokens, all shoved into the same Redux blob. But those are different kinds of state with different lifecycles, and they want different tools. Zustand, React Query and AsyncStorage each solve exactly one of these problems well. Draw the boundaries between them and the architecture mostly sorts itself out.

Three kinds of state
#

Before arguing about libraries, it helps to name what you’re actually managing.

Client state exists only in your app’s runtime: UI toggles, the selected tab, form inputs, whether a modal is open. Nothing upstream owns it, and it dies with the process.

Server state is different. The server owns it; your app just holds a local copy. User profiles, product listings, notifications, feed data. Your copy goes stale, needs refetching, and other clients may be looking at a different version of it right now.

Persistent state is whatever has to survive a restart: auth tokens, the onboarding-complete flag, cached preferences, offline data. It lives on the device itself.

Most apps have all three. Most messes come from handling all three with one tool.

Zustand: client state
#

Zustand is a small state library for React with none of the ceremony. No providers, no boilerplate, no context wrappers stacked five deep. You create a store, you use it in components, and that’s the whole lesson.

It’s the right tool when several components need the same UI state (sidebar open or closed, active filters, selected items), when app-level state doesn’t come from an API (theme, language, feature flags read at startup), or when there’s real client-side logic to manage, like cart calculations or a multi-step wizard. Anything that should update synchronously and predictably.

A basic store:

import { create } from 'zustand'

interface AppState {
  theme: 'light' | 'dark'
  sidebarOpen: boolean
  selectedFilters: string[]
  setTheme: (theme: 'light' | 'dark') => void
  toggleSidebar: () => void
  setFilters: (filters: string[]) => void
}

const useAppStore = create<AppState>((set) => ({
  theme: 'light',
  sidebarOpen: false,
  selectedFilters: [],
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setFilters: (filters) => set({ selectedFilters: filters }),
}))

And in a component:

function Sidebar() {
  const { sidebarOpen, toggleSidebar } = useAppStore()

  if (!sidebarOpen) return null

  return (
    <div className="sidebar">
      <button onClick={toggleSidebar}>Close</button>
      {/* sidebar content */}
    </div>
  )
}

What I don’t put in Zustand
#

API responses, mainly. I’ve seen projects where every API call writes into a Zustand store and components read from the store instead of querying. You end up reimplementing cache invalidation, loading states, error handling, refetching and pagination by hand, which is exactly the work React Query exists to do for you.

The other thing is anything that must survive a restart. Zustand state lives in memory; when the app closes, it’s gone. The persist middleware can bridge into AsyncStorage (more on that later), but that should be a deliberate decision rather than a habit.

React Query: server state
#

React Query (TanStack Query these days) manages the whole lifecycle of remote data: fetching, caching, synchronising, updating, garbage collecting. The mental shift is that it treats server data as a cache you keep fresh, not as state you own. The server owns it. You’re holding a copy.

I use it for anything that comes from an API. That covers data multiple components need from the same endpoint (requests get deduplicated automatically), paginated and infinite-scroll lists, data that should refetch in the background when someone returns to the app, and optimistic updates where the UI changes immediately and rolls back if the server says no.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
  })
}

function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: { id: string; name: string }) =>
      fetch(`/api/users/${data.id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['user', variables.id] })
    },
  })
}

In a component:

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId)
  const updateUser = useUpdateUser()

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}
        disabled={updateUser.isPending}
      >
        Update Name
      </button>
    </div>
  )
}

What you get without writing it
#

The reason I’m firm about putting server data here is the sheer amount of machinery that comes free. Five components requesting the same user produces one network request. Data refetches when the window regains focus or the network reconnects. Old cache entries get garbage collected on their own. Every query hands you isLoading, isError, data and friends, so loading and error UI stops being bespoke work. Failed requests retry with exponential backoff. And optimistic updates with rollback are a built-in pattern rather than an afternoon of custom plumbing.

What it’s not for
#

Client-only state. If the data never touches a server, React Query just adds ceremony; a modal’s open state doesn’t need cache invalidation or a refetch interval.

It’s also not a persistence layer. The cache lives in memory, so a restart means an empty cache and a round of refetching. You can persist the cache (and on React Native you should, see the offline section below), but the source of truth is always the server.

AsyncStorage: what survives a restart
#

AsyncStorage is React Native’s key-value storage. On the web the equivalent is localStorage (synchronous) or IndexedDB (async, more capable). Same idea either way: data that outlives the process because it’s written to the device.

What it actually is on each platform
#

One API, three quite different backends, assuming you’re using @react-native-async-storage/async-storage.

On Android it’s SQLite under the hood, via RKStorage, in a database inside the app’s internal storage directory. Fast, reliable, and sandboxed so other apps can’t touch it. Mind the default size cap in the table below.

On iOS it’s NSUserDefaults for small values and serialised files for larger ones, again sandboxed inside the app container. Apple doesn’t set a hard limit on NSUserDefaults, but the sensible convention is to keep individual values under a few hundred KB. If your values are bigger than that, you probably want a proper database like WatermelonDB or Realm anyway.

On web (React Native Web or Expo Web) it falls back to localStorage, which caps out around 5-10 MB depending on the browser. Web-only React apps can use localStorage directly, or IndexedDB through a wrapper like idb-keyval for larger datasets.

PlatformBackendSize LimitLocation
AndroidSQLite (RKStorage)~6 MB default (configurable)App internal storage
iOSNSUserDefaults / filesNo hard limit (keep values small)App sandbox container
WeblocalStorage~5-10 MB (browser-dependent)Browser origin storage

What I keep in it
#

Auth tokens and session data, preferences that should stick around (language, theme, notification settings), the onboarding-completed flag, cached data for offline use. Small key-value things that need to survive a restart; that’s the whole category.

The API is about as plain as storage gets:

import AsyncStorage from '@react-native-async-storage/async-storage'

// Store a value
await AsyncStorage.setItem('auth_token', token)

// Read a value
const token = await AsyncStorage.getItem('auth_token')

// Store an object (must serialize)
await AsyncStorage.setItem('user_preferences', JSON.stringify({
  theme: 'dark',
  language: 'en',
  notifications: true,
}))

// Read an object
const prefs = JSON.parse(await AsyncStorage.getItem('user_preferences') ?? '{}')

// Remove a value
await AsyncStorage.removeItem('auth_token')

// Clear everything (careful with this)
await AsyncStorage.clear()

The web versions
#

Web-only React apps don’t need AsyncStorage at all. localStorage covers the simple cases:

// Synchronous  - blocks the main thread, but fine for small data
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme')

// For structured data
localStorage.setItem('user', JSON.stringify({ name: 'Jared', role: 'admin' }))
const user = JSON.parse(localStorage.getItem('user') ?? '{}')

And IndexedDB covers the bigger ones:

import { get, set, del } from 'idb-keyval'

await set('large-dataset', hugeArray)
const data = await get('large-dataset')
await del('large-dataset')

What it’s not for
#

It’s a key-value store, not a database. Relational data, arrays with thousands of items, anything that needs indexing or querying: that’s SQLite (via expo-sqlite), WatermelonDB or Realm territory.

It’s also not secure storage. On a rooted or jailbroken device, AsyncStorage contents are readable. Sensitive tokens belong in expo-secure-store or react-native-keychain instead.

How the three fit together
#

Real apps use all three at once. A few patterns I lean on constantly.

The auth flow
#

// 1. AsyncStorage: persist the auth token
import AsyncStorage from '@react-native-async-storage/async-storage'

async function saveToken(token: string) {
  await AsyncStorage.setItem('auth_token', token)
}

async function getToken(): Promise<string | null> {
  return AsyncStorage.getItem('auth_token')
}

// 2. Zustand: track auth state in memory
import { create } from 'zustand'

interface AuthState {
  isAuthenticated: boolean
  token: string | null
  setAuth: (token: string) => void
  clearAuth: () => void
}

const useAuthStore = create<AuthState>((set) => ({
  isAuthenticated: false,
  token: null,
  setAuth: (token) => set({ isAuthenticated: true, token }),
  clearAuth: () => set({ isAuthenticated: false, token: null }),
}))

// 3. React Query: fetch user profile using the token
function useCurrentUser() {
  const token = useAuthStore((s) => s.token)

  return useQuery({
    queryKey: ['currentUser'],
    queryFn: () =>
      fetch('/api/me', {
        headers: { Authorization: `Bearer ${token}` },
      }).then(res => res.json()),
    enabled: !!token, // Only fetch when we have a token
  })
}

And on startup:

// App initialization
async function initializeApp() {
  const token = await getToken() // Read from AsyncStorage
  if (token) {
    useAuthStore.getState().setAuth(token) // Put in Zustand for quick access
    // React Query will automatically fetch the user profile
  }
}

AsyncStorage keeps the token across restarts, Zustand makes it cheap to read from anywhere, and React Query fetches the profile the moment a token exists. Each tool does the one thing it’s good at.

A theme that survives restarts
#

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'

// Zustand with persistence middleware bridges the gap
const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light',
        })),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => AsyncStorage), // React Native
      // storage: createJSONStorage(() => localStorage), // Web
    }
  )
)

Zustand manages the runtime state and the persist middleware syncs it to AsyncStorage (or localStorage on web) behind the scenes. Your components neither know nor care that a persistence layer exists.

Offline-first reads
#

import { useQuery } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      try {
        const res = await fetch('/api/products')
        const data = await res.json()

        // Cache in AsyncStorage for offline use
        await AsyncStorage.setItem('cached_products', JSON.stringify(data))

        return data
      } catch (error) {
        // Network failed  - try cached data
        const cached = await AsyncStorage.getItem('cached_products')
        if (cached) return JSON.parse(cached)
        throw error
      }
    },
    staleTime: 10 * 60 * 1000,
  })
}

React Query does the fetching and in-memory caching; AsyncStorage is the fallback when the network isn’t there.

Offline mode, step by step
#

This is the part I actually care about. The general wisdom is that people open apps on the subway, in lifts, on planes, and that’s true. In my case the requirement is blunter: quotes get written up on site, and I can’t assume the site has reception. A spinner in a basement is a lost quote.

The nice surprise is that these three libraries get you a genuinely solid offline setup without reaching for a heavy framework.

Step 1: know when you’re offline
#

The device needs to tell the app about the network, and the app needs somewhere to keep that answer. @react-native-community/netinfo provides the events; a tiny Zustand store makes the state readable everywhere.

import { create } from 'zustand'
import NetInfo from '@react-native-community/netinfo'

interface NetworkState {
  isOnline: boolean
  setOnline: (online: boolean) => void
}

const useNetworkStore = create<NetworkState>((set) => ({
  isOnline: true,
  setOnline: (online) => set({ isOnline: online }),
}))

// Subscribe once at app startup
NetInfo.addEventListener((state) => {
  useNetworkStore.getState().setOnline(state.isConnected ?? false)
})

Now any component can check useNetworkStore((s) => s.isOnline) to show an offline banner, disable a submit button, or explain that changes will sync later.

Step 2: tell React Query about offline
#

React Query has offline behaviour built in through networkMode, which controls what queries and mutations do when the network is out.

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      // Return cached data immediately, then refetch in background when online
      staleTime: 5 * 60 * 1000,
      gcTime: 24 * 60 * 60 * 1000, // Keep cache for 24 hours
      retry: (failureCount, error) => {
        // Don't retry if we're offline  - it'll just fail again
        if (!useNetworkStore.getState().isOnline) return false
        return failureCount < 3
      },
    },
    mutations: {
      networkMode: 'offlineFirst',
    },
  },
})

The three modes:

ModeBehaviour
online (default)Queries only fire when online. Pauses when offline.
alwaysQueries fire regardless of network. Your queryFn handles failures.
offlineFirstQueries fire once (for cached data), then pause until online to refetch.

For most mobile apps offlineFirst is the one you want: cached data appears immediately, and fresh data arrives when the network allows.

Step 3: persist the query cache
#

Out of the box the cache is memory-only, so a restart means spinners everywhere. For offline mode you persist it to AsyncStorage:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 24 * 60 * 60 * 1000, // 24 hours  - must be >= maxAge
    },
  },
})

const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'react-query-cache',
})

// In your App component
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: asyncStoragePersister,
        maxAge: 24 * 60 * 60 * 1000, // Don't restore data older than 24 hours
        dehydrateOptions: {
          shouldDehydrateQuery: (query) => {
            // Only persist successful queries
            return query.state.status === 'success'
          },
        },
      }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}

Now someone opening the app in a dead zone sees the last-fetched data straight away instead of a blank screen, and React Query quietly refetches once the network returns.

Step 4: queue writes while offline
#

Reads are the easy half. Writes are where offline gets interesting: someone adds a comment, submits an order or edits their profile with no network, and that change has to be queued and replayed later.

React Query’s answer is useMutation with an optimistic update in onMutate:

import { useMutation, useQueryClient } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

interface Comment {
  id: string
  text: string
  postId: string
  createdAt: string
  pending?: boolean
}

function useAddComment(postId: string) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (text: string) => {
      const res = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      })
      return res.json()
    },

    // Optimistic update  - show the comment immediately
    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] })

      const previous = queryClient.getQueryData<Comment[]>(['comments', postId])

      const optimisticComment: Comment = {
        id: `temp-${Date.now()}`,
        text,
        postId,
        createdAt: new Date().toISOString(),
        pending: true, // Show a "sending..." indicator in the UI
      }

      queryClient.setQueryData<Comment[]>(
        ['comments', postId],
        (old) => [...(old ?? []), optimisticComment]
      )

      return { previous }
    },

    // Roll back on failure
    onError: (err, text, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['comments', postId], context.previous)
      }
    },

    // Refetch to get the real data from the server
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['comments', postId] })
    },
  })
}

With networkMode: 'offlineFirst' on mutations, the mutationFn pauses until the network is back. The optimistic update puts the comment on screen immediately, and the real API call fires on reconnect.

If you need a more serious queue, say dozens of pending changes that have to replay in order, you can persist the mutation queue as well:

import { MutationCache } from '@tanstack/react-query'

// Save pending mutations to AsyncStorage
const mutationCache = new MutationCache({
  onError: async (error, variables, context, mutation) => {
    // Log failed mutations for debugging
    const pending = JSON.parse(
      await AsyncStorage.getItem('pending_mutations') ?? '[]'
    )
    pending.push({
      key: mutation.options.mutationKey,
      variables,
      timestamp: Date.now(),
    })
    await AsyncStorage.setItem('pending_mutations', JSON.stringify(pending))
  },
})

Step 5: reconcile when the network returns
#

React Query does most of the reconnection work itself: paused mutations resume, stale queries refetch. You just have to wire its managers up to React Native’s events.

import NetInfo from '@react-native-community/netinfo'
import { onlineManager, focusManager } from '@tanstack/react-query'
import { AppState } from 'react-native'

// Tell React Query about network state changes
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected)
  })
})

// Refetch when the app comes back to foreground
focusManager.setEventListener((setFocused) => {
  const subscription = AppState.addEventListener('change', (status) => {
    setFocused(status === 'active')
  })
  return () => subscription.remove()
})

When someone backgrounds the app for an hour and comes back, focusManager triggers refetches so they see fresh data. When they walk out of a tunnel and regain signal, onlineManager resumes whatever was paused.

Step 6: say so in the UI
#

Whatever else you do, don’t swallow network errors silently. Tell people they’re offline and show which changes are still pending.

import { useNetworkStore } from './stores/network'

function OfflineBanner() {
  const isOnline = useNetworkStore((s) => s.isOnline)

  if (isOnline) return null

  return (
    <View style={styles.banner}>
      <Text>You're offline. Changes will sync when you reconnect.</Text>
    </View>
  )
}

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <View style={[styles.comment, comment.pending && styles.pending]}>
      <Text>{comment.text}</Text>
      {comment.pending && (
        <Text style={styles.pendingLabel}>Sending...</Text>
      )}
    </View>
  )
}

The whole picture
#

┌─────────────────────────────────────────────────┐
│                   Components                     │
│  useQuery() for reads    useMutation() for writes│
└──────────┬──────────────────────┬────────────────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │ React Query │        │ React Query │
     │   Cache     │        │  Mutation   │
     │ (in-memory) │        │   Queue     │
     └─────┬──────┘        └─────┬──────┘
           │                      │
     ┌─────▼──────────────────────▼──────┐
     │     AsyncStorage Persister         │
     │  (survives app restart)            │
     └─────┬──────────────────────┬──────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │   Zustand   │        │   Network  │
     │ (isOnline,  │◄───────│   NetInfo  │
     │  UI state)  │        │            │
     └────────────┘        └────────────┘
LayerToolRole
Network detectionZustand + NetInfoTrack online/offline, drive UI banners
Data fetchingReact QueryFetch when online, serve cache when offline
Cache persistenceReact Query + AsyncStorageRestore cache on app restart
Offline writesReact Query mutationsQueue mutations, replay on reconnect
Optimistic UIReact Query onMutateShow changes immediately, roll back on failure
App focus syncReact Query focusManagerRefetch stale data when app returns to foreground

When you need a real sync engine
#

All of the above assumes the server is the source of truth and offline is temporary. If you need true offline-first with conflict resolution (think a notes app where two devices edit the same document offline), this stack won’t cut it and you want a proper sync engine. WatermelonDB is built for React Native, runs on SQLite and ships a sync protocol for resolving conflicts. Realm with Atlas Device Sync is a full offline-first database with automatic conflict resolution through MongoDB Atlas. PowerSync is a SQLite-based sync layer that works with your existing Postgres backend. And if you need total control, Expo SQLite plus your own sync logic is always an option.

Zustand + React Query + AsyncStorage covers about 80% of mobile apps. The other 20%, the collaborative editors and multi-device offline-heavy workflows, need a dedicated sync database.

Deciding where a piece of state goes
#

When I’m not sure where something belongs, I run it through these questions:

QuestionYes → Use
Does it come from a server/API?React Query
Is it client-only UI state shared across components?Zustand
Does it need to survive an app restart?AsyncStorage (+ optionally Zustand persist)
Is it sensitive (tokens, passwords)?expo-secure-store / react-native-keychain
Is it large structured data that needs querying?SQLite / WatermelonDB / Realm
Is it a simple form input used by one component?useState

Common cases
#

StateToolWhy
API response dataReact QueryCaching, dedup, refetch, loading states
Selected tab / active filterZustandClient-only, multiple components care
Auth tokenAsyncStorage + ZustandPersists across restarts, fast in-memory access
Theme preferenceZustand with persist middlewareClient state that should survive restarts
Shopping cartZustand with persist middlewareComplex client logic, should survive restarts
Form inputuseStateSingle component, no need to share
User profile from APIReact QueryServer state, might be stale
Onboarding completed flagAsyncStorageJust a boolean that persists
Offline cached feedReact Query + AsyncStorageFetch from server, fall back to cache

Setup by platform
#

React Native (Android + iOS)
#

All three at once:

npm install zustand @tanstack/react-query @react-native-async-storage/async-storage

Plus secure storage for anything sensitive:

npx expo install expo-secure-store
# or
npm install react-native-keychain

Expo
#

AsyncStorage works out of the box with Expo, no native linking required.

npx expo install @react-native-async-storage/async-storage

Web-only React
#

Skip AsyncStorage entirely and use localStorage or IndexedDB directly.

npm install zustand @tanstack/react-query
# Optional for IndexedDB
npm install idb-keyval

Zustand’s persist middleware already defaults to localStorage on web:

persist(storeConfig, {
  name: 'my-store',
  // localStorage is the default on web  - no extra config needed
})

Mistakes I keep seeing
#

Putting API data in Zustand. If there’s a setUsers(apiResponse.users) in one of your actions, stop. That’s React Query’s job, and you’re one sprint away from reinventing cache invalidation badly.

Using React Query for client state. If the queryFn doesn’t make a network request, wrong tool.

Treating AsyncStorage like a database. Serialising an array of 10,000 items into a key-value store ends in tears. Use SQLite or a real database.

Leaving tokens unencrypted. AsyncStorage is not secure storage. Auth tokens, API keys and credentials go in expo-secure-store or the platform keychain.

Hand-rolling persistence. If you’re reading AsyncStorage on mount and writing on every state change, Zustand’s persist middleware already does that, plus hydration and serialisation, with less code and fewer bugs.


Server data goes through React Query. Client state lives in Zustand. Anything that must survive a restart goes to AsyncStorage, and anything sensitive goes to secure storage.

The worst architectures I’ve worked on all had one giant store that everything flowed through. The best ones had clear lines between server state, client state and what needs to persist. Draw those lines early and everything after gets easier.