+1 (415) 943-4271

State Management in React Native: Server State, Client State, and Where Zustand Fits

The problem: one store for everything

Many React Native codebases start with a single global store holding API responses, form drafts, and UI flags. It works until you need caching, refetching, optimistic updates, or persistence, and then the store becomes a pile of hand-written loading flags. The fix is to split state by kind, not by screen.

Four kinds of state

KindExampleTool
Server stateProduct list, user profileTanStack Query
Client/global stateAuth session, theme, cart draftZustand (or Jotai)
Local UI stateModal open, input textuseState / useReducer
URL/navigation stateSelected tab, item idExpo Router params

If you can refetch it from a server, it is not client state. Treat it as a cache.

Server state with TanStack Query

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

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => api.get('/products'),
    staleTime: 60_000,
  });
}

export function useRenameProduct() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: api.renameProduct,
    onMutate: async (vars) => {
      await qc.cancelQueries({ queryKey: ['products'] });
      const prev = qc.getQueryData(['products']);
      qc.setQueryData(['products'], (old: any[]) =>
        old.map((p) => (p.id === vars.id ? { ...p, name: vars.name } : p)));
      return { prev };
    },
    onError: (_e, _v, ctx) => qc.setQueryData(['products'], ctx?.prev),
    onSettled: () => qc.invalidateQueries({ queryKey: ['products'] }),
  });
}

React Native specifics: wire focusManager to AppState and onlineManager to NetInfo (or expo-network) so queries refetch when the app returns to the foreground and pause when offline.

import { AppState } from 'react-native';
import { focusManager } from '@tanstack/react-query';

AppState.addEventListener('change', (s) => focusManager.setFocused(s === 'active'));

Client state with Zustand

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

type CartState = {
  items: Record<string, number>;
  add: (id: string) => void;
  clear: () => void;
};

export const useCart = create<CartState>()(
  persist(
    (set) => ({
      items: {},
      add: (id) => set((s) => ({ items: { ...s.items, [id]: (s.items[id] ?? 0) + 1 } })),
      clear: () => set({ items: {} }),
    }),
    { name: 'cart', storage: createJSONStorage(() => AsyncStorage) },
  ),
);

Always select narrowly so components re-render only when needed:

const count = useCart((s) => Object.keys(s.items).length);

Selecting the whole store is the most common cause of needless re-renders. For object selections use useShallow.

Pitfalls we see in audits

  1. Copying query data into a store. You now have two sources of truth. Read from the query; derive the rest.
  2. Persisting secrets. Do not put tokens in AsyncStorage-backed persisted stores; use secure storage.
  3. Unversioned persisted state. Set version and migrate in persist, or app updates will hydrate stale shapes and crash.
  4. Hydration flashes. Gate rendering on useStore.persist.hasHydrated() or keep the splash screen up until hydration completes.
  5. Context for fast-changing values. Context re-renders every consumer; use a store with selectors instead.
  6. Giant query keys built ad hoc. Create a small key factory per feature so invalidation is predictable.

When to choose something else

  • Jotai suits many small, derived atoms with fine-grained updates.
  • Redux Toolkit remains reasonable on large teams that value strict conventions and tooling.
  • React Compiler reduces manual memoization but does not replace a state architecture.

A migration path

  1. Inventory your store: tag each field as server, client, or local.
  2. Move server fields to queries one feature at a time, deleting loading/error flags as you go.
  3. Shrink what remains into small Zustand stores per domain.
  4. Add tests around the hooks, not the store internals.

The result is less code, fewer loading-state bugs, and screens that stay correct when the network does not. If you want a second pair of eyes on your app's state architecture, get in touch.