State Management Basics
10 examples to get you started with State Management - 7 basic and 3 intermediate. Mobile users lose signal constantly; classify state before picking a library.
Search across all documentation pages
10 examples to get you started with State Management - 7 basic and 3 intermediate. Mobile users lose signal constantly; classify state before picking a library.
Scaffold a production-shaped Expo app with an explicit SDK 57 pin. State examples assume Expo Router and TypeScript.
npx create-expo-app@latest MyApp --template default@sdk-57
cd MyApp
npm installConfirm the SDK pin before adding state libraries:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}Install the default server-state layer most teams adopt on day one:
npx expo install @tanstack/react-query @react-native-community/netinfoTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Modal open, sheet expanded, pressed tab index - state that dies when the screen unmounts belongs in useState on that screen.
// app/(tabs)/catalog/index.tsx
import { useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
export default function CatalogScreen() {
const [filterOpen, setFilterOpen] = useState(false);
return (
<View style={{ flex: 1, padding: 16 }}>
<Pressable onPress={() => setFilterOpen(true)}>
<Text>Filters</Text>
</Pressable>
<Modal visible={filterOpen} animationType="slide" onRequestClose={() => setFilterOpen(false)}>
<View style={{ flex: 1, padding: 24 }}>
<Text>Filter sheet - local state only</Text>
<Pressable onPress={() => setFilterOpen(false)}>
<Text>Close</Text>
</Pressable>
</View>
</Modal>
</View>
);
}filterOpen does not need Redux, Zustand, or Context - no other screen reads itRelated: useState & useReducer - when local state outgrows a single screen
Props flow down; state is owned by the component that updates it. Mobile list items should stay presentational.
// src/features/catalog/components/ProductRow.tsx
import { Pressable, Text, View } from "react-native";
type Props = {
title: string;
priceLabel: string;
selected: boolean;
onPress: () => void;
};
export function ProductRow({ title, priceLabel, selected, onPress }: Props) {
return (
<Pressable onPress={onPress} style={{ padding: 12, backgroundColor: selected ? "#e0f2fe" : "#fff" }}>
<Text style={{ fontWeight: "600" }}>{title}</Text>
<Text>{priceLabel}</Text>
</Pressable>
);
}// Parent owns selection state
import { useState } from "react";
import { FlatList } from "react-native";
import { ProductRow } from "@/features/catalog/components/ProductRow";
const PRODUCTS = [
{ id: "1", title: "Trail Pack", priceLabel: "$89" },
{ id: "2", title: "Day Pack", priceLabel: "$49" },
];
export function ProductList() {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<FlatList
data={PRODUCTS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ProductRow
title={item.title}
priceLabel={item.priceLabel}
selected={selectedId === item.id}
onPress={() => setSelectedId(item.id)}
/>
)}
/>
);
}ProductRow has no useState - re-renders only when props changeselectedId is the single source of truth for highlight stateonPress keeps navigation and side effects in the container layerRelated: ../component-patterns/container-presenter-on-mobile/container-presenter-on-mobile.md - split data hooks from JSX
Server state comes from an API and goes stale. Client state is UI the user controls. Mixing them in one useState blob causes refetch bugs.
// ❌ Anti-pattern - API data in useState with manual useEffect
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/orders")
.then((r) => r.json())
.then(setOrders)
.finally(() => setLoading(false));
}, []);// ✅ Server state in TanStack Query; client state stays local
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
function OrdersScreen() {
const [sort, setSort] = useState<"newest" | "oldest">("newest");
const { data: orders = [], isPending, isError, refetch } = useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
staleTime: 60_000,
});
const sorted = [...orders].sort((a, b) =>
sort === "newest" ? b.placedAt.localeCompare(a.placedAt) : a.placedAt.localeCompare(b.placedAt)
);
// render sorted, isPending, isError, refetch, setSort...
}orders are server-owned - cache, dedupe, and background refresh belong in Querysort is client-owned - no network round-trip when the user togglesrefetch() - not a manual setOrdersRelated: TanStack Query - cache policies for mobile | ../architecture-design/adr-state-management-selection/adr-state-management-selection.md - decision matrix
Cart badge, theme mode, onboarding completion - state that survives navigation but is not API data fits a small global store or split Context.
// src/features/cart/useCartStore.ts
import { create } from "zustand";
type CartItem = { productId: string; qty: number };
type CartStore = {
items: CartItem[];
add: (item: CartItem) => void;
count: () => number;
};
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
count: () => get().items.reduce((n, i) => n + i.qty, 0),
}));// Tab bar reads count via selector - not the whole store
import { useCartStore } from "@/features/cart/useCartStore";
import { Text, View } from "react-native";
export function CartTabIcon() {
const count = useCartStore((s) => s.count());
return (
<View>
<Text>Cart</Text>
{count > 0 && <Text>{count}</Text>}
</View>
);
}npx expo install zustand - pure JS, no native moduleRelated: Zustand - slices, devtools, and tests
Filters, tabs, and pagination that should restore on deep link belong in route search params - not a global store.
// app/(tabs)/catalog/index.tsx
import { router, useLocalSearchParams } from "expo-router";
import { Pressable, Text, View } from "react-native";
type Params = { category?: string };
export default function CatalogRoute() {
const { category = "all" } = useLocalSearchParams<Params>();
return (
<View style={{ padding: 16, gap: 8 }}>
<Text>Category: {category}</Text>
{(["all", "gear", "apparel"] as const).map((c) => (
<Pressable key={c} onPress={() => router.setParams({ category: c })}>
<Text style={{ fontWeight: category === c ? "700" : "400" }}>{c}</Text>
</Pressable>
))}
</View>
);
}useLocalSearchParams reads the current route's query string equivalentrouter.setParams updates params without losing stack position - OS back restores prior filtermyapp://catalog?category=gear) work when scheme is set in app.config.tsRelated: Typed Routes - typed params in SDK 57
On mobile, assume the user is offline in an elevator. Show last good data with a subtle offline banner - not a blank screen.
import NetInfo from "@react-native-community/netinfo";
import { onlineManager, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { Text, View } from "react-native";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected))
);
function ProductList() {
const { data, isPending, isFetching, isError } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60_000,
gcTime: 24 * 60 * 60_000,
retry: 2,
});
const showOfflineCache = !isPending && data && isError;
return (
<View>
{showOfflineCache && <Text>Offline - showing saved results</Text>}
{isFetching && !isPending && <Text>Updating…</Text>}
{/* render data */}
</View>
);
}staleTime keeps cached data visible while a background refetch runsonlineManager tells Query when the device reconnects - triggers refetchOnReconnectgcTime (formerly cacheTime) controls how long unused cache survives in memoryRelated: TanStack Query -
focusManageron AppState | ../error-resilience/network-failure-ux/network-failure-ux.md - offline UX patterns
Wire server-state and session providers once in app/_layout.tsx - avoid nesting providers per screen.
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useState } from "react";
import { SessionProvider } from "@/features/auth/SessionProvider";
export default function RootLayout() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 2 },
},
})
);
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<Stack screenOptions={{ headerShown: false }} />
</SessionProvider>
</QueryClientProvider>
);
}QueryClient is created once per app session - useState(() => new QueryClient()) avoids sharing between tests and prod accidentally in the same moduleSessionProvider exposes auth identity - tokens live behind expo-secure-store, not in Query cacheRelated: Context Without Storms - split contexts when providers re-render too much
A checkout screen combines cart (client), shipping options (server), and selected method (client). Three buckets, three tools.
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useCartStore } from "@/features/cart/useCartStore";
export function CheckoutScreen() {
const items = useCartStore((s) => s.items);
const [shippingId, setShippingId] = useState<string | null>(null);
const { data: methods = [], isPending } = useQuery({
queryKey: ["shipping-methods", items.length],
queryFn: () => fetchShippingMethods(items),
enabled: items.length > 0,
});
// UI: cart summary from Zustand, methods from Query, selection from useState
}items.length for invalidationshippingId is ephemeral UI on this screen - useState until submit, then mutationRelated: ../architecture-design/clean-architecture-on-mobile/clean-architecture-on-mobile.md - use cases vs hooks
Persist Query cache to AsyncStorage so catalog screens render immediately after process kill - hydrate before first paint when possible.
// src/lib/queryPersister.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
export const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "REACT_QUERY_OFFLINE_CACHE",
});// app/_layout.tsx (excerpt)
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { asyncStoragePersister } from "@/lib/queryPersister";
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister, maxAge: 1000 * 60 * 60 * 24 }}
>
{children}
</PersistQueryClientProvider>npx expo install @react-native-async-storage/async-storage @tanstack/react-query-persist-client @tanstack/query-async-storage-persistermaxAge caps how old persisted cache can be - pair with staleTime per queryqueryClient.clear() and wipe the persister - stale user data is a security bugRelated: State Persistence & Hydration - cold-start without jank
Run this checklist when a teammate proposes Redux, Jotai, or another store.
State checklist (answer before npm install):
1. Is it from an API? → TanStack Query (or RTK Query if Redux already)
2. Is it shareable in a URL? → Expo Router search params
3. Is it a multi-field form? → React Hook Form + mutation on submit
4. Is it global client UI? → Zustand or split Context
5. Is it ephemeral on one screen? → useState / useReducer
6. Is it a secret token? → expo-secure-store + thin SessionProvider
7. Does compliance need audit? → Redux Toolkit + DevTools
If two libraries answer the same "yes" - pick one and document in an ADR.// Document the team default in a comment or ADR link at the store boundary
/** @see docs/adr-state-management - server: Query, client: Zustand, URL: Router */
export const usePreferences = create<PreferencesStore>(/* ... */);Related: ../architecture-design/adr-state-management-selection/adr-state-management-selection.md - ranked decisions | Best Practices - section summary
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026