Saltar al contenido principal
60 min de lectura3 de agosto de 2026
ReactZustandReact QueryEstadoData Fetching

Requisitos previos

  • react-framework

Lo que aprenderás

  • Decidir cuándo usar Context, Zustand o React Query
  • Crear stores con Zustand, slices y middleware persist
  • Cachear datos con React Query y controlar su ciclo de vida
  • Invalidar y actualizar caché tras mutations
  • Integrar data fetching en aplicaciones Next.js
Tutorial60 min

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.

Antes de añadir una librería de estado, pregúntate: ¿puedo subir el estado a un ancestro común? ¿Puedo componer el árbol de otra forma? Muchos casos de "prop drilling" se resuelven reorganizando componentes, no añadiendo dependencias.

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 useContext depende 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.
El famoso "boilerplate de Context" no es el contexto en sí, sino los 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.
Regla práctica: Context para estado que cambia poco (tema, idioma, proveedor de datos), store para estado que cambia mucho y se comparte (sesión, carrito, UI efímera) y React Query para estado del servidor (lo que viene de una API). Esta distinción es el corazón de este tutorial.

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;
}
El error del custom hook es tu mejor amigo: convierte un fallo silencioso (el contexto 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.

No uses el Context para cachear datos del servidor. Un provider que hace 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 zustand

create: 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);
Nunca selecciones un objeto completo que se construya de nuevo en cada cambio. Si el selector devuelve un objeto literal o un .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"] });
    },
  });
}
El optimistic update solo tiene sentido cuando la operación es idempotente y casi nunca falla (toggle, like, favorito). Para operaciones con validación compleja del servidor, un loading state honesto con 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>
  );
}
Deshabilitar el botón "Siguiente" con 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é.

Página estática + revalidación + CDN es la receta de los Core Web Vitals para contenido público: el HTML llega casi sin coste de servidor y el TTFB se reduce drásticamente.

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.
No hay una sola respuesta: una app real usa los cuatro. Context para el tema, Zustand para el carrito, React Query para los productos y SSG para la página de inicio. Saber cuándo cada uno es la elección correcta es exactamente lo que este tutorial te ha enseñado.

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.

Siguiente lección

Next.js: apps fullstack

Domina Next.js con App Router, SSR, SSG, ISR, API Routes y optimización avanzada de rendimiento.