| name | mobile-state-management |
|---|---|
| description | Choose and implement state management for a React Native/Expo app. Covers React state, Zustand, Jotai, and React Query with guidance on when to use each. Use when the user needs to manage global state, server state, or form state. |
| standards-version | 1.7.0 |
Use this skill when the user:
- Asks how to manage global or shared state in a React Native app
- Wants to fetch and cache server data
- Needs help choosing between state management libraries
- Is dealing with prop drilling or context performance issues
- Mentions "state", "Zustand", "Jotai", "React Query", "TanStack Query", "context", "store", or "cache"
- State type: What kind of state the user needs (UI state, server/API data, form state, auth state)
- Current setup (optional): What they are using now (plain React state, Context, Redux, etc.)
- Data sources (optional): What APIs or backends they fetch from
-
Identify the state category. Different state types need different solutions:
State type Best tool Why Local component UI (toggle, input) useState/useReducerNo library needed, keep it simple Global UI state (theme, sidebar open) Zustand Lightweight, no providers, works outside React Server/API data (lists, user profile) React Query (TanStack Query) Handles caching, refetching, loading/error states Derived/computed atoms Jotai Fine-grained reactivity, no re-render cascading Complex form state React Hook Form Validation, field arrays, performance Auth state Zustand + SecureStore Persist tokens securely on device -
Set up Zustand for global UI state. Install it:
npx expo install zustand
Create a store in
store/useAppStore.ts:import { create } from "zustand"; interface AppState { theme: "light" | "dark"; setTheme: (theme: "light" | "dark") => void; onboardingComplete: boolean; completeOnboarding: () => void; } export const useAppStore = create<AppState>((set) => ({ theme: "light", setTheme: (theme) => set({ theme }), onboardingComplete: false, completeOnboarding: () => set({ onboardingComplete: true }), }));
Usage in a component:
import { useAppStore } from "@/store/useAppStore"; function ThemeToggle() { const theme = useAppStore((s) => s.theme); const setTheme = useAppStore((s) => s.setTheme); return ( <Switch value={theme === "dark"} onValueChange={(v) => setTheme(v ? "dark" : "light")} /> ); }
-
Add persistence with Zustand middleware. For state that survives app restarts:
npx expo install @react-native-async-storage/async-storage
import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; export const useAppStore = create<AppState>()( persist( (set) => ({ theme: "light", setTheme: (theme) => set({ theme }), onboardingComplete: false, completeOnboarding: () => set({ onboardingComplete: true }), }), { name: "app-storage", storage: createJSONStorage(() => AsyncStorage), }, ), );
-
Set up React Query for server state. Install it:
npx expo install @tanstack/react-query
Create the provider in
app/_layout.tsx:import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes retry: 2, }, }, }); export default function RootLayout() { return ( <QueryClientProvider client={queryClient}> <Stack /> </QueryClientProvider> ); }
Create a query hook in
hooks/useUsers.ts:import { useQuery } from "@tanstack/react-query"; async function fetchUsers() { const res = await fetch("https://api.example.com/users"); if (!res.ok) throw new Error("Failed to fetch users"); return res.json(); } export function useUsers() { return useQuery({ queryKey: ["users"], queryFn: fetchUsers }); }
Usage:
function UserList() { const { data, isLoading, error } = useUsers(); if (isLoading) return <ActivityIndicator />; if (error) return <Text>Error: {error.message}</Text>; return ( <FlatList data={data} keyExtractor={(u) => u.id} renderItem={({ item }) => <Text>{item.name}</Text>} /> ); }
-
Use Jotai for fine-grained derived state. Install:
npx expo install jotai
import { atom, useAtom } from "jotai"; const countAtom = atom(0); const doubledAtom = atom((get) => get(countAtom) * 2); function Counter() { const [count, setCount] = useAtom(countAtom); const [doubled] = useAtom(doubledAtom); return ( <View> <Text>Count: {count}, Doubled: {doubled}</Text> <Button title="+" onPress={() => setCount((c) => c + 1)} /> </View> ); }
Jotai is ideal when you have many small, interdependent pieces of state and want to avoid unnecessary re-renders. Zustand is better for fewer, larger state slices.
-
Recommend a typical stack. For most Expo apps:
- Zustand for global UI state and auth
- React Query for all API/server data
- React state for local component state
- Avoid Redux unless the team already uses it
User: "My app fetches a list of products from an API and I also need a shopping cart that persists when the app closes."
Agent:
- Sets up React Query for the products API (caching, loading states, refetch on focus)
- Creates a Zustand store with persist middleware for the cart (items, add, remove, clear)
- Uses AsyncStorage as the persistence backend
- Shows how to combine both in a product list screen: React Query fetches products, Zustand manages the cart
- Installs dependencies using
mobile_installDependency
| Step | MCP Tool | Description |
|---|---|---|
| Install Zustand | mobile_installDependency |
Run npx expo install zustand |
| Install React Query | mobile_installDependency |
Run npx expo install @tanstack/react-query |
| Generate store file | mobile_generateComponent |
Scaffold a typed store file |
| Generate hook file | mobile_generateComponent |
Scaffold a custom query hook |
- Using Context for everything - React Context re-renders all consumers on every state change. It works for low-frequency updates (theme, locale) but not for frequently changing state (form inputs, counters).
- Fetching in useEffect - React Query replaces the
useEffect+useState+loading+errorpattern. Using rawuseEffectfor data fetching leads to race conditions and missing cache invalidation. - Storing server data in Zustand - Zustand is for client state. API data belongs in React Query. Mixing them creates stale data and duplicate cache logic.
- Not setting staleTime - Without
staleTime, React Query refetches on every component mount. Set it to at least 60 seconds for most endpoints. - Persisting sensitive data with AsyncStorage - AsyncStorage is unencrypted. For auth tokens, use
expo-secure-storeinstead. - Selector-less Zustand usage - Always use selectors (
useAppStore((s) => s.theme)) to avoid re-rendering on unrelated state changes.
- Mobile Navigation Setup - set up routes before wiring state to screens
- Mobile Component Patterns - build components that consume state cleanly