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

One of the most common mistakes in React and React Native projects is shoving everything into a single state management solution. You end up with a Redux store that handles API caching, user preferences, form state, and authentication tokens all in one tangled mess. These are different kinds of state, and they need different tools.

Zustand, React Query, and AsyncStorage each solve a different problem. Once you understand the boundaries between them, your architecture gets a lot cleaner.

The Three Types of State
#

Before picking tools, understand what you’re actually managing.

Client state is data that exists only in your app’s runtime. Think UI toggles, selected tabs, form inputs, modal visibility, theme preferences. It only exists while the app is running and doesn’t come from a server.

Server state is data that lives on a remote server and your app just has a local copy. User profiles, product listings, notifications, feed data. It can go stale, it needs to be refetched, and multiple clients might be looking at different versions of it.

Persistent state is data that needs to survive app restarts. Auth tokens, onboarding completion flags, cached user preferences, offline data. It lives on the device itself.

Most apps have all three. The mistake is using one tool for everything.

Zustand: Client State Done Right
#

Zustand is a lightweight state management library for React. No providers, no boilerplate, no context wrapper hell. You create a store, you use it in components, and that’s it.

When to Use Zustand
#

  • UI state that multiple components need (sidebar open/closed, active filters, selected items)
  • App-level state that doesn’t come from an API (theme, language, feature flags loaded at startup)
  • Complex client-side logic (shopping cart calculations, multi-step form wizards)
  • State that needs to be updated synchronously and predictably

Basic Example
#

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 }),
}))

Using it 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 Zustand Is Not For
#

Don’t use Zustand to cache API responses. I’ve seen projects where every API call writes into a Zustand store, and then components read from the store instead of querying the API. You end up reimplementing cache invalidation, loading states, error handling, refetching, and pagination - all things React Query gives you for free.

Don’t use Zustand for data that needs to persist across app restarts. Zustand state lives in memory. When the app closes, it’s gone. You can add persistence middleware (more on that later), but at that point you’re bridging into AsyncStorage territory and should be intentional about it.

React Query: Server State Without the Pain
#

React Query (TanStack Query) manages the entire lifecycle of remote data. Fetching, caching, synchronizing, updating, and garbage collecting. It treats server data as a cache that needs to be kept fresh, not as state that you own.

When to Use React Query
#

  • Any data that comes from an API
  • Data that multiple components need from the same endpoint (it deduplicates requests automatically)
  • Paginated or infinite scroll data
  • Data that needs background refetching (user comes back to the app, data might be stale)
  • Optimistic updates (update UI immediately, roll back if the server rejects it)

Basic Example
#

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] })
    },
  })
}

Using it 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 for Free
#

  • Automatic deduplication: Five components requesting the same user? One network request.
  • Background refetching: Data refetches when the window regains focus or the network reconnects.
  • Cache management: Old data is garbage collected automatically.
  • Loading and error states: Every query gives you isLoading, isError, data, and more.
  • Retry logic: Failed requests retry automatically with exponential backoff.
  • Optimistic updates: Update the UI before the server confirms, and roll back on failure.

What React Query Is Not For
#

Don’t use React Query for client-only state. If the data doesn’t come from a server, React Query adds unnecessary complexity. A modal’s open/closed state doesn’t need cache invalidation or refetch intervals.

Don’t use React Query as a long-term persistence layer. Its cache lives in memory. When the app restarts, the cache is empty and everything refetches. You can persist the cache (and should for React Native apps), but the source of truth is always the server.

AsyncStorage: Persistent State Across Platforms
#

AsyncStorage is the key-value storage solution for React Native. On the web, the equivalent is localStorage (synchronous) or IndexedDB (async, more powerful). Same idea - data that survives app restarts by living on the device.

Platform Implementations
#

The storage backend is different on each platform, but the API is the same if you’re using @react-native-async-storage/async-storage:

Android: Uses SQLite under the hood through RKStorage. Data is stored in a SQLite database in the app’s internal storage directory. It’s fast, reliable, and has no practical size limit (though Android may enforce per-app storage limits). The data is sandboxed to your app - other apps can’t access it.

iOS: Uses NSUserDefaults for small values and serialized data files for larger ones. Like Android, data is sandboxed within your app’s container. Apple doesn’t impose a hard limit on NSUserDefaults, but best practice is to keep individual values under a few hundred KB. For larger data, consider using a proper database like WatermelonDB or Realm.

Web (React Native Web / Expo Web): Falls back to localStorage, which has a ~5-10 MB limit depending on the browser. For web-only React apps, you can use localStorage directly or go with IndexedDB via 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

When to Use AsyncStorage
#

  • Auth tokens and session data
  • User preferences that should persist (language, theme, notification settings)
  • Onboarding completion flags
  • Cached data for offline-first experiences
  • Any small key-value data that needs to survive app restarts

Basic Example
#

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()

Web Equivalent
#

For web-only React apps, you don’t need AsyncStorage. Use localStorage for simple key-value pairs:

// 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') ?? '{}')

For larger datasets on the web, use IndexedDB:

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

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

What AsyncStorage Is Not For
#

Don’t use AsyncStorage as your primary data store. It’s a key-value store, not a database. If you’re storing relational data, arrays of thousands of items, or anything that needs indexing and querying, use SQLite (via expo-sqlite), WatermelonDB, or Realm.

Don’t store sensitive data in AsyncStorage without encryption. On Android and iOS, AsyncStorage data is accessible if the device is rooted/jailbroken. For sensitive tokens, use expo-secure-store or react-native-keychain instead.

How They Work Together
#

In a real app, these three tools work together.

Example: User Authentication 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
  })
}

On app 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
  }
}

Example: Theme Preference with Persistence
#

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 automatically syncs to AsyncStorage (or localStorage on web). Your components don’t know or care about the persistence layer.

Example: Offline-First Data
#

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 handles the fetching and in-memory caching. AsyncStorage provides the offline fallback.

Building Offline Mode for Mobile Apps
#

Offline support is not optional for mobile. Users open your app on the subway, in elevators, on flights, in areas with spotty cell coverage. If your app shows a blank screen or a spinner every time the network drops, users will leave.

Zustand, React Query, and AsyncStorage together give you a solid offline architecture without reaching for heavy frameworks.

Step 1: Detect Network State
#

First, you need to know when the device is online or offline. Use @react-native-community/netinfo and track the state in Zustand so your entire app can react.

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)
})

Any component can check useNetworkStore((s) => s.isOnline) and show an offline banner, disable buttons, or display a “changes will sync when online” message.

Step 2: Configure React Query for Offline
#

React Query has built-in offline support through networkMode. This controls how queries and mutations behave when the device is offline.

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 network modes:

ModeBehavior
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 right choice. Users see cached data immediately, and fresh data loads when the network is available.

Step 3: Persist the React Query Cache
#

By default, React Query’s cache lives in memory. App restart = empty cache = loading spinners everywhere. For offline mode, you need to persist the cache to AsyncStorage so it survives restarts.

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>
  )
}

When the user opens the app offline, they see the last-fetched data right away instead of a blank screen. When the network comes back, React Query refetches in the background.

Step 4: Queue Mutations While Offline
#

Reading cached data offline is easy. Handling writes is harder. When a user creates a post, submits an order, or updates their profile while offline, you need to queue that mutation and replay it when the network comes back.

React Query handles this with useMutation and onMutate for optimistic updates:

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' set on mutations, React Query will pause the mutationFn until the network is back. The optimistic update shows the comment immediately in the UI, and the actual API call fires once the device reconnects.

For more complex offline queues (like syncing dozens of pending changes in order), you can persist the mutation queue:

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: Sync When Back Online
#

When the network returns, you need to reconcile local changes with the server. React Query handles most of this automatically - paused mutations resume, stale queries refetch. But you should also handle edge cases.

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 a user backgrounds your 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 paused mutations.

Step 6: Show Offline State in the UI
#

Don’t silently swallow network errors. Tell users when they’re offline and when their changes are 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>
  )
}

Putting the Offline Architecture Together
#

How all three tools fit together for offline mode:

┌─────────────────────────────────────────────────┐
│                   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 to Reach for Something Heavier
#

This works well when the server is the source of truth and offline is temporary. If your app needs real offline-first with conflict resolution - like a notes app where two devices edit the same document offline - you’ll want a proper sync engine:

  • WatermelonDB: Built for React Native, uses SQLite under the hood, has a sync protocol for resolving conflicts
  • Realm (Atlas Device Sync): Full offline-first database with automatic conflict resolution via MongoDB Atlas
  • PowerSync: SQLite-based sync layer that works with your existing Postgres backend
  • Expo SQLite + custom sync: Roll your own if you need full control

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

Decision Framework
#

Not sure where a piece of state belongs? Ask 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 Patterns
#

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

Platform-Specific Considerations
#

React Native (Android + iOS)
#

Install all three:

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

For sensitive data, add secure storage:

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

Expo
#

AsyncStorage works out of the box with Expo. No native linking needed.

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

Web-Only React Apps
#

You don’t need AsyncStorage on the web. Use localStorage or IndexedDB directly.

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

Zustand’s persist middleware supports localStorage natively:

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

Anti-Patterns to Avoid
#

Putting API data in Zustand. If you’re writing setUsers(apiResponse.users) in a Zustand action, stop. Use React Query. You’re about to reinvent cache invalidation, and it won’t go well.

Using React Query for client state. If your queryFn doesn’t make a network request, you’re using the wrong tool. React Query is for server state.

Storing large data in AsyncStorage. AsyncStorage is a key-value store. If you’re serializing arrays of 10,000 items, use a database.

Not encrypting tokens. AsyncStorage is not secure storage. Auth tokens, API keys, and user credentials should go in expo-secure-store or the platform’s keychain.

Skipping the persist middleware. If you’re manually reading from AsyncStorage on mount and writing on every state change, just use Zustand’s persist middleware. It handles hydration, serialization, and storage sync for you.


Server data goes through React Query. Client state goes in Zustand. Persistent data goes in AsyncStorage. Sensitive data goes in secure storage.

The worst architectures I’ve worked on are the ones where everything flows through one giant store. The best ones have clear lines between what’s server state, what’s client state, and what needs to persist. Figure that out early and everything else gets easier.