Conceptos básicos de gestión de estado
10 ejemplos para empezar con gestión de estado - 7 básicos y 3 intermedios. Los usuarios móviles pierden señal constantemente; clasifica el estado antes de elegir una librería.
Busca en todas las páginas de la documentación
10 ejemplos para empezar con gestión de estado - 7 básicos y 3 intermedios. Los usuarios móviles pierden señal constantemente; clasifica el estado antes de elegir una librería.
Crea un app Expo de forma productiva con un pin explícito del SDK 57. Los ejemplos de estado asumen Expo Router y TypeScript.
npx create-expo-app@latest MyApp --template default@sdk-57
cd MyApp
npm installConfirma el pin del SDK antes de añadir librerías de estado:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}Instala la capa de estado del servidor que la mayoría de equipos adoptan desde el primer día:
npx expo install @tanstack/react-query @react-native-community/netinfoHerramientas: Estos ejemplos están dirigidos a Expo SDK 57 (
expo~57.0.4), React Native 0.86.0 y React 19.2.3.
Modal abierto, sheet expandida, índice de tab presionado - estado que muere cuando la pantalla se desmonta pertenece en useState en esa pantalla.
// 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>Filtros</Text>
</Pressable>
<Modal visible={filterOpen} animationType="slide" onRequestClose={() => setFilterOpen(false)}>
<View style={{ flex: 1, padding: 24 }}>
<Text>Sheet de filtros - solo estado local</Text>
<Pressable onPress={() => setFilterOpen(false)}>
<Text>Cerrar</Text>
</Pressable>
</View>
</Modal>
</View>
);
}filterOpen no necesita Redux, Zustand, o Context - ninguna otra pantalla lo leeRelacionado: useState y useReducer - cuando el estado local crece más allá de una sola pantalla
Props bajan; estado es propio del componente que lo actualiza. Los elementos de listas móviles deben mantenerse presentacionales.
// 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>
);
}// El padre es propietario del estado de selección
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 no tiene useState - se re-renderiza solo cuando los props cambianselectedId del padre es la única fuente de verdad para el estado de resaltadoonPress mantiene la navegación y los efectos secundarios en la capa del contenedorRelacionado: ../component-patterns/container-presenter-on-mobile/container-presenter-on-mobile.md - separar hooks de datos de JSX
Estado del servidor viene de una API y se vuelve obsoleto. Estado del cliente es UI que el usuario controla. Mezclarlos en un blob useState causa bugs de refetch.
// ❌ Anti-patrón - datos de API en useState con useEffect manual
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/orders")
.then((r) => r.json())
.then(setOrders)
.finally(() => setLoading(false));
}, []);// ✅ Estado del servidor en TanStack Query; estado del cliente se queda 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)
);
// renderizar sorted, isPending, isError, refetch, setSort...
}orders son propiedad del servidor - caché, deduplicación y actualización en segundo plano pertenecen en Querysort es propiedad del cliente - sin viaje de red cuando el usuario lo cambiarefetch() - no a un setOrders manualRelacionado: TanStack Query - políticas de caché para móvil | ../architecture-design/adr-state-management-selection/adr-state-management-selection.md - matriz de decisión
Insignia del carrito, modo tema, finalización de incorporación - estado que persiste en la navegación pero no es datos de API se ajusta a una pequeña tienda global o Context dividido.
// 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),
}));// La barra de tabs lee el conteo mediante selector - no la tienda completa
import { useCartStore } from "@/features/cart/useCartStore";
import { Text, View } from "react-native";
export function CartTabIcon() {
const count = useCartStore((s) => s.count());
return (
<View>
<Text>Carrito</Text>
{count > 0 && <Text>{count}</Text>}
</View>
);
}npx expo install zustand - JavaScript puro, sin módulo nativoRelacionado: Zustand - slices, devtools y pruebas
Filtros, tabs y paginación que deberían restaurarse en deep link pertenecen en parámetros de búsqueda de ruta - no en una tienda global.
// 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>Categoría: {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 lee la cadena de consulta equivalente de la ruta actualrouter.setParams actualiza parámetros sin perder la posición en la pila - el botón atrás del SO restaura el filtro anteriormyapp://catalog?category=gear) funcionan cuando scheme se establece en app.config.tsRelacionado: ../expo-router/search-params-and-typed-routes/search-params-and-typed-routes.md - parámetros tipados en SDK 57
En móvil, asume que el usuario está sin conexión en un ascensor. Muestra últimos datos buenos con un banner de offline sutil - no una pantalla en blanco.
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>Sin conexión - mostrando resultados guardados</Text>}
{isFetching && !isPending && <Text>Actualizando…</Text>}
{/* renderizar data */}
</View>
);
}staleTime mantiene los datos en caché visibles mientras una actualización en segundo plano se ejecutaonlineManager le dice a Query cuándo el dispositivo se reconecta - dispara refetchOnReconnectgcTime (anteriormente cacheTime) controla cuánto tiempo la caché sin usar sobrevive en la memoriaRelacionado: TanStack Query -
focusManageren AppState | ../error-resilience/network-failure-ux/network-failure-ux.md - patrones de UX offline
Conecta proveedores de estado del servidor y sesión una vez en app/_layout.tsx - evita anidar proveedores por pantalla.
// 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 se crea una vez por sesión de la app - useState(() => new QueryClient()) evita compartir entre pruebas y producción accidentalmente en el mismo móduloSessionProvider expone identidad de autenticación - los tokens viven detrás de expo-secure-store, no en la caché de QueryRelacionado: Context Without Storms - divide contextos cuando los proveedores re-renderizen demasiado
Una pantalla de checkout combina carrito (cliente), opciones de envío (servidor) y método seleccionado (cliente). Tres cubos, tres herramientas.
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: resumen del carrito de Zustand, métodos de Query, selección de useState
}items.length para invalidaciónshippingId es UI efímera en esta pantalla - useState hasta enviar, luego mutaciónRelacionado: ../architecture-design/clean-architecture-on-mobile/clean-architecture-on-mobile.md - casos de uso vs hooks
Persiste la caché de Query en AsyncStorage para que las pantallas del catálogo se rendericen inmediatamente después de la muerte del proceso - hidrata antes del primer renderizado cuando sea posible.
// 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 (fragmento)
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 limita cuán vieja puede ser la caché persistida - combina con staleTime por queryqueryClient.clear() y limpiar el persister - datos de usuario obsoletos es un bug de seguridadRelacionado: State Persistence & Hydration - inicio en frío sin jank
Ejecuta este checklist cuando un compañero propone Redux, Jotai, u otra tienda.
Checklist de estado (responde antes de npm install):
1. ¿Viene de una API? -> TanStack Query (o RTK Query si Redux ya está)
2. ¿Se comparte en una URL? -> Parámetros de búsqueda de Expo Router
3. ¿Es un formulario multi-campo? -> React Hook Form + mutación al enviar
4. ¿Es UI global del cliente? -> Zustand o Context dividido
5. ¿Es efímera en una pantalla? -> useState / useReducer
6. ¿Es un token secreto? -> expo-secure-store + SessionProvider delgado
7. ¿Compliance necesita auditoría? -> Redux Toolkit + DevTools
Si dos librerías responden el mismo "sí" - elige una y documenta en un ADR.// Documenta el estándar del equipo en un comentario o enlace ADR en el límite de la tienda
/** @see docs/adr-state-management - servidor: Query, cliente: Zustand, URL: Router */
export const usePreferences = create<PreferencesStore>(/* ... */);Relacionado: ../architecture-design/adr-state-management-selection/adr-state-management-selection.md - decisiones clasificadas | Best Practices - resumen de la sección
Versiones de stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0 y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026