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
| Kind | Example | Tool |
|---|---|---|
| Server state | Product list, user profile | TanStack Query |
| Client/global state | Auth session, theme, cart draft | Zustand (or Jotai) |
| Local UI state | Modal open, input text | useState / useReducer |
| URL/navigation state | Selected tab, item id | Expo 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
- Copying query data into a store. You now have two sources of truth. Read from the query; derive the rest.
- Persisting secrets. Do not put tokens in AsyncStorage-backed persisted stores; use secure storage.
- Unversioned persisted state. Set
versionandmigrateinpersist, or app updates will hydrate stale shapes and crash. - Hydration flashes. Gate rendering on
useStore.persist.hasHydrated()or keep the splash screen up until hydration completes. - Context for fast-changing values. Context re-renders every consumer; use a store with selectors instead.
- 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
- Inventory your store: tag each field as server, client, or local.
- Move server fields to queries one feature at a time, deleting loading/error flags as you go.
- Shrink what remains into small Zustand stores per domain.
- 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.