Estado global y data fetching
Cuando una app crece, pasar datos de componente en componente se convierte en un dolor: props interminables, re-renders inesperados y lógica duplicada. Este tutorial cubre el estado global con Context y Zustand, la capa de datos con React Query y cuándo delegar el fetching a Next.js. Prerequisitos: React (hooks y componentes) y consumo básico de APIs.
¿Por qué estado global?
El estado en React es local por defecto: vive dentro del componente que lo declara. Eso está bien mientras los datos no se compartan. En cuanto dos componentes que no son padre-hijo necesitan los mismos datos, empieza el problema.
Prop drilling
El "prop drilling" es pasar un dato por cada nivel del árbol aunque los niveles intermedios no lo usen, solo para que llegue al destino:
function App() {
const [user, setUser] = useState(null);
return <Dashboard user={user} onLogin={setUser} />;
}
// Dashboard no usa user, solo lo reenvía…
function Dashboard({ user, onLogin }) {
return <Sidebar user={user} onLogin={onLogin} />;
}
// …Sidebar tampoco…
function Sidebar({ user, onLogin }) {
return <ProfileMenu user={user} onLogin={onLogin} />;
}
// …hasta que llega a quien lo necesita.
function ProfileMenu({ user, onLogin }) {
return user ? <Avatar user={user} /> : <LoginButton onLogin={onLogin} />;
}Los problemas son evidentes: los componentes intermedios reciben props que no usan (se acoplan a datos que no les importan), cualquier cambio de forma del dato obliga a tocar toda la cadena, y el código grita que hay algo mal diseñado.
Las limitaciones del Context
La solución nativa de React es Context: un valor disponible para todo un subárbol sin pasar props. Su mayor problema es el rendimiento:
- Re-renders globales: cada vez que el valor del contexto cambia, todos los consumidores del subárbol se re-renderizan, aunque consuman una parte que no cambió.
- Sin selectores: no puedes suscribirte a una "slice" del valor. Todo componente que use
useContextdepende del objeto completo. - Mezcla de responsabilidades: si además pones data fetching en el provider, cada consulta de la API re-renderiza toda la app.
useMemo que necesitas para evitar re-renders innecesarios cada vez que el provider se renderiza. Con valores frecuentemente cambiantes (sesión, carrito), ese coste se nota.Cuándo necesitas un store
Un store externo (Zustand, Redux, Jotai) aporta lo que Context no da: selectores finos, suscripciones por trozo y middleware (persistencia, devtools). Conviene considerarlo cuando:
- El estado es global de verdad: sesión, tema, carrito, preferencias. No es un dato aislado de una pantalla.
- Muchos componentes leen el mismo dato y solo una fracción debería re-renderizarse cuando cambia.
- Necesitas persistencia (localStorage, sessionStorage) o herramientas de depuración con viajes en el tiempo.
- La lógica de mutación es compleja y quieres aislarla en acciones testeables.
Context API avanzado
Context no es malo: es el mecanismo correcto para ciertos casos. Lo importante es usarlo bien: tipado, provider dedicado, custom hook y memoización.
Contexto tipado
El valor del contexto se declara con una interfaz explícita. El hook createContext recibe un valor por defecto que, como nunca debe usarse, se declara como null:
// auth-context.ts
import { createContext, useContext } from "react";
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextValue {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
// null porque el valor real solo existe dentro del provider
export const AuthContext = createContext<AuthContextValue | null>(null);Provider y custom hook
El provider guarda el estado con useState y expone un hook propio que lanza un error claro si alguien lo usa fuera del árbol:
// auth-provider.tsx
import { useState, useMemo, type ReactNode } from "react";
import { AuthContext, type User } from "./auth-context";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo<AuthContextValue>(
() => ({
user,
login: (nextUser) => setUser(nextUser),
logout: () => setUser(null),
}),
[user],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth debe usarse dentro de <AuthProvider>");
}
return ctx;
}null) en un mensaje que dice exactamente qué componente está mal colocado.useMemo y re-renders
Sin useMemo, cada render del provider crearía un objeto nuevo para value, y el contexto notificaría a todos sus consumidores aunque el estado no hubiera cambiado. Esa memoización es lo que separa un provider correcto de uno que ralentiza la app:
const value = useMemo<AuthContextValue>(
() => ({ user, login: (u) => setUser(u), logout: () => setUser(null) }),
[user], // solo cambia cuando user cambia
);Aun con esto, cada consumidor del contexto se re-renderiza cuando user cambia, lea o no user. Eso es inherente al diseño de Context.
Límites del Context
Context no escala para estado que cambia a alta frecuencia (posición de ratón, contenido de un editor, websockets con updates constantes). Para esos casos necesitas suscripciones selectivas, y ahí entran los stores con selectores. Una señal clara de que Context no basta: te ves creando cinco contextos distintos solo para partir el estado y reducir re-renders.
fetch al montarse y guarda el resultado en useState no te da ni caché, ni reintentos, ni deduplicación de peticiones. Ese trabajo es de React Query.Zustand
Zustand es un store mínimo sobre React: creas un estado con create, lo lees con hooks y lo modificas con acciones. No requiere provider y los re-renders se limitan a los componentes que usan el selector correcto.
Instalación
npm install zustandcreate: estado y acciones
El store se define fuera del componente. El set permite actualizar todo el estado o aplicar una función sobre el estado actual:
// cart-store.ts
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clear: () => void;
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));En el componente, el hook devuelve una parte del estado. Cada propiedad que se selecciona es una suscripción separada:
// CartButton.tsx
import { useCartStore } from "./cart-store";
export function CartButton() {
// solo re-renderiza cuando items cambia
const items = useCartStore((state) => state.items);
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem({ id: "p1", name: "Camiseta", price: 19 })}>
Añadir (hay {items.length} en el carrito)
</button>
);
}Selectores
El selector determina la suscripción: devuelve la mínima porción de estado que el componente necesita. Cuanto más fino, menos re-renders:
// Bueno: suscripción fina, re-render solo si cambia el nombre
const name = useCartStore((state) => state.user.name);
// Malo: suscripción a un objeto nuevo en cada cambio
const user = useCartStore((state) => state.user);.filter(), Zustand comparará referencias con Object.is y verá un valor "distinto" en cada render, provocando un loop infinito. Para derivar datos se usa useShallow:import { useShallow } from "zustand/react/shallow";
// Combina varios campos sin re-render excesivo
const { name, isAdmin } = useCartStore(
useShallow((state) => ({
name: state.user.name,
isAdmin: state.user.role === "admin",
})),
);Slices: partir el store en módulos
Cuando el store crece, se divide en slices: cada slice es una función que recibe set (y get) y devuelve su parte del estado. El store final las combina:
// slices/user-slice.ts
interface UserSlice {
userName: string;
setUserName: (name: string) => void;
}
const createUserSlice: StateCreator<UserSlice> = (set) => ({
userName: "",
setUserName: (name) => set({ userName: name }),
});
// slices/settings-slice.ts
interface SettingsSlice {
theme: "light" | "dark";
toggleTheme: () => void;
}
const createSettingsSlice: StateCreator<SettingsSlice> = (set) => ({
theme: "light",
toggleTheme: () =>
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
});
// store.ts — combina los slices
type AppStore = UserSlice & SettingsSlice;
export const useAppStore = create<AppStore>()((...args) => ({
...createUserSlice(...args),
...createSettingsSlice(...args),
}));Middleware persist
El middleware persist guarda el estado en localStorage y lo rehidrata al recargar. Solo hay que indicar qué campos persistir:
// theme-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface ThemeStore {
theme: "light" | "dark";
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "light",
toggleTheme: () =>
set((state) => ({
theme: state.theme === "light" ? "dark" : "light",
})),
}),
{
name: "theme-storage", // clave en localStorage
partialize: (state) => ({ theme: state.theme }),
},
),
);partialize evita persistir funciones (acciones) y datos sensibles. Para valores de gran tamaño o de acceso lento, cambia el storage por createJSONStorage(() => sessionStorage) o un storage propio.React Query (TanStack Query)
React Query (ahora TanStack Query) resuelve el estado del servidor: caché, revalidación, reintentos y sincronización con la UI. Es ortogonal a Zustand: Zustand guarda estado de cliente, React Query guarda respuestas de API.
QueryClient y provider
El QueryClient se crea una sola vez y se comparte con QueryClientProvider:
// providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
// useState lazy: se crea una única instancia por montaje
const [client] = useState(() => new QueryClient());
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}useQuery
useQuery recibe una queryKey (identifica la caché) y una queryFn (devuelve una promesa). La caché hace que dos componentes con la misma clave compartan la misma petición:
// use-posts.ts
import { useQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error("Error al cargar los posts");
return res.json();
}
export function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 60_000, // 60s sin refetch
gcTime: 5 * 60_000, // 5min hasta que se limpia de la caché
});
}La query devuelve flags de estado que controlan la UI:
// PostList.tsx
export function PostList() {
const { data, isLoading, isError, error, refetch } = usePosts();
if (isLoading) return <p>Cargando posts…</p>;
if (isError) return (
<div>
<p>Error: {error.message}</p>
<button onClick={() => refetch()}>Reintentar</button>
</div>
);
return (
<ul>
{data?.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
);
}staleTime y gcTime no son lo mismo: stale es cuánto tiempo se considera fresco el dato (no se revalida al volver a la pantalla); gcTime es cuánto tiempo sobrevive en caché sin consumidores. Los datos "stale" se siguen mostrando y se revalidan en segundo plano.useMutation e invalidación
Para escrituras (POST, PUT, DELETE) se usa useMutation. Al terminar, invalidateQueries marca la query como obsoleta y la vuelve a pedir con datos frescos:
// create-post.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface NewPost {
title: string;
body: string;
}
async function createPost(input: NewPost) {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("No se pudo crear el post");
return res.json();
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPost,
onSuccess: () => {
// ["posts"] queda obsoleto → refetch automático
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}Optimistic updates
En una mutación optimista, la UI refleja el cambio al instante y se revierte si falla. El patrón: cancelar queries en curso, guardar el valor anterior, aplicar el cambio y restaurarlo en error:
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: string;
title: string;
done: boolean;
}
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/todos/${id}/toggle`, { method: "PATCH" });
if (!res.ok) throw new Error("Error al actualizar");
return res.json();
},
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo,
),
);
return { previous };
},
onError: (_error, _id, context) => {
queryClient.setQueryData(["todos"], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}isPending es mejor que una UI que miente.Queries con dependencias
Los parámetros forman parte de la queryKey y la query se desactiva con enabled hasta que el dato esté listo:
// use-posts-by-user.ts
import { useQuery } from "@tanstack/react-query";
export function usePostsByUser(userId: string | null) {
return useQuery({
queryKey: ["posts", userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}/posts`);
if (!res.ok) throw new Error("Error al cargar posts");
return res.json();
},
enabled: userId !== null, // no dispara la petición sin userId
});
}Paginación con placeholderData
En la paginación, placeholderData mantiene visible la página anterior mientras se carga la nueva, sin parpadeos de skeleton:
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
interface Page<T> {
items: T[];
totalPages: number;
}
async function fetchPage(page: number): Promise<Page<Post>> {
const res = await fetch(`/api/posts?page=${page}`);
return res.json();
}
export function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["posts", "page", page],
queryFn: () => fetchPage(page),
// mantiene la página anterior como placeholder
placeholderData: (previousData) => previousData,
});
return (
<div>
<ul>
{data?.items.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
<button
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Anterior
</button>
<button
disabled={isPlaceholderData || page === data?.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Siguiente
</button>
</div>
);
}isPlaceholderData evita que el usuario salte dos páginas de golpe mientras la anterior aún está en caché.Data fetching en Next.js
Next.js ofrece otra dimensión: decidir dónde se hace el fetching. En el servidor, en el cliente, o en ambos según la naturaleza del dato.
Servidor vs cliente
- Servidor: datos públicos o por usuario (en tu propia base de datos) se obtienen en build o por request. Ventaja: cero latencia de red extra, mejor SEO y LCP, sin estados de carga.
- Cliente: datos privados de terceros, acciones en tiempo real o consultas dependientes de la interacción del usuario van con React Query, que además las cachea y revalida.
En el pages router, el fetching en servidor se declara en la página con getStaticProps (estático) o getServerSideProps (por request):
// pages/blog/index.tsx
import type { GetStaticProps } from "next";
interface Post {
id: number;
title: string;
excerpt: string;
}
interface BlogProps {
posts: Post[];
}
export const getStaticProps: GetStaticProps<BlogProps> = async () => {
const res = await fetch("https://api.example.com/public/posts");
const posts: Post[] = await res.json();
return {
props: { posts },
revalidate: 3600, // ISR: regenera como máximo cada hora
};
};
export default function BlogPage({ posts }: BlogProps) {
return (
<main>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</main>
);
}SSG e ISR para datos públicos
El contenido que no cambia a cada segundo (artículos, documentación, landing) se genera estático en build y se sirve desde CDN. Con revalidate activas ISR: Next regenera la página en segundo plano cuando expira, sin derribar la copia en caché.
Streams y rendering progresivo
El streaming envía el HTML por partes: el shell de la página aparece al instante y las secciones lentas (una lista de datos, un widget) llegan cuando están listas. El usuario ve contenido en cuanto existe, en lugar de esperar a que todo el documento termine de generarse. El patrón moderno en App Router son los async components y loading.tsx, que dibujan el placeholder del segmento mientras el dato se resuelve.
Comparativa final
La herramienta correcta depende del tipo de dato, no de lo que está de moda. Esta es la guía práctica:
- Context: estado compartido que cambia poco — tema, idioma, proveedor de auth. Inclusión nativa, sin dependencias.
- Zustand: estado de cliente que cambia mucho y se lee desde muchos sitios — carrito, sesión activa, UI efímera. Selectores finos y persistencia.
- React Query: todo lo que viene de una API — caché, revalidación, mutaciones, optimistic updates. Nunca guardes datos del servidor en Zustand ni en Context.
- Fetching directo: datos públicos y mayormente estáticos, resueltos en el servidor con SSG/ISR. No necesitas nada más.
Ejercicios
El estado global y el data fetching no son un problema de "qué librería uso", sino de clasificar los datos: los que cambian poco (Context), los que cambian mucho en el cliente (Zustand) y los que vienen de una API (React Query o el servidor de Next.js). Con esa clasificación clara, cada decisión de arquitectura se responde sola.