Módulo 18: Estado en el cliente y en la URL

Objetivos del módulo

  • Saber dónde debe vivir cada dato de una aplicación Next.js: en el servidor, en la URL, en una caché del cliente, en un almacén global o en el propio componente
  • Usar la URL como estado: leer searchParams en el servidor, validarlos y guardarlos en caché con 'use cache' y argumentos
  • Leer y cambiar la cadena de consulta desde el cliente con useSearchParams, router.replace, router.push, <Form> y la History API del navegador, sabiendo qué peticiones provoca cada opción (medido)
  • Entender por qué useSearchParams exige <Suspense> con Cache Components y qué ocurre en el build si lo olvidas
  • Saber qué estado conserva Next.js 16 al navegar gracias a <Activity>, y qué no, en comparación con la URL y un almacén
  • Montar proveedores de Context en un Client Component sin convertir la aplicación entera en cliente
  • Usar Zustand en Next.js sin compartir datos entre usuarios, con persist y sin errores de hidratación
  • Combinar TanStack Query con Server Components: proveedor, contrato de caché compartido, precarga en el servidor con HydrationBoundary, el problema de Date.now() con Cache Components y la coordinación de mutaciones con updateTag
  • Construir la tienda y la gestión de pedidos de EMPRESA con cada tipo de estado en su sitio

💡 Consejo: Este módulo da por sabidas las bases de React. TanStack Query se explica a fondo en el módulo 19 del Curso de React, y Zustand y Context en el módulo 20 del Curso de React. Aquí no se repite lo general: nos centramos en lo que cambia cuando hay un servidor que renderiza, una caché de Next.js y un enrutador que conserva páginas ocultas. También conviene tener a mano el módulo 03 (navegación y <Activity>), el módulo 04 (searchParams), el módulo 05 (límites entre servidor y cliente) y el módulo 09 (Cache Components).


1. Cinco sitios para guardar el estado

En una SPA de React clásica (React con Vite, por ejemplo) todo el estado vive en el navegador. Los datos de la API se descargan con fetch desde un componente, se guardan en useState, en Redux o en TanStack Query, y el servidor solo es una API que responde JSON. Por eso los cursos de React dedican tanto espacio a las bibliotecas de estado: son la única forma de no repetir peticiones y de compartir datos entre componentes.

Next.js cambia el reparto. Los Server Components piden los datos en el servidor y envían al navegador la interfaz ya construida. El listado de productos de la tienda no necesita estar en ningún almacén del cliente: lo genera el servidor, lo guarda la caché de Next ('use cache', módulo 09) y llega como HTML. Gran parte de lo que en una SPA era “estado global” desaparece.

Lo que queda se reparte entre cinco sitios:

Cinco columnas: servidor, URL, caché del cliente, global del cliente y local, con ejemplos de EMPRESA en cada una y una flecha que va de lo compartido y duradero a lo rápido y privado

Figura 1. Dónde vive cada estado. Cuanto más a la derecha, más cerca del navegador: más rápido de cambiar, pero más fácil de perder.

Sitio Herramienta en Next.js Ejemplos en EMPRESA Sobrevive a recargar Se comparte con un enlace
Servidor Server Components, 'use cache', base de datos Catálogo, pedidos, usuario de la sesión Sí Sí (si el otro usuario tiene permiso)
URL searchParams, useSearchParams Búsqueda, categoría, orden, página, pestaña Sí Sí
Caché del cliente TanStack Query Pedidos que se refrescan solos, scroll infinito No (se vuelve a pedir) No
Global del cliente Zustand, Context Carrito, vista tarjetas/lista, avisos Solo con persist o cookie No
Local useState, useReducer Texto que se escribe, menú abierto, fila seleccionada No No

1.1 Lo que el servidor ya resuelve

Antes de añadir una biblioteca de estado a un proyecto Next.js, pregúntate si el dato no es simplemente un dato del servidor. Estas son las situaciones en que una SPA necesitaría estado global y Next.js no:

  • Datos que muestra una página: los pide un Server Component. No hay estado de carga en el cliente ni caché que mantener: el <Suspense> del servidor (módulo 08) se encarga del streaming.
  • Datos que comparten varias páginas (el nombre del usuario en la cabecera y en el perfil): cada Server Component los pide, y cache de React o 'use cache' evitan repetir la consulta.
  • Datos que cambian tras una mutación: una Server Action con updateTag o refresh (módulo 10) hace que el servidor vuelva a renderizar lo afectado. No hay que actualizar a mano ninguna caché del cliente.

📘 Concepto: En Next.js, el estado de servidor (los datos de negocio) vive en el servidor y llega renderizado. El estado de cliente queda reducido a lo que de verdad pertenece al navegador: lo que el usuario está haciendo ahora mismo (un campo a medio escribir, un menú abierto), sus preferencias de esta sesión (el carrito, la vista) y los pocos datos que deben actualizarse sin pasar por una navegación (un panel en vivo).

1.2 Preguntas para decidir

Cuando aparezca un estado nuevo, recorre estas preguntas en orden. La primera respuesta afirmativa te dice dónde guardarlo:

  1. ¿Es un dato de negocio que está en la base de datos o en la API? Va al servidor. Léelo en un Server Component.
  2. ¿Debería conservarse si el usuario copia el enlace, lo guarda en favoritos o pulsa Atrás? Va a la URL. Una búsqueda, un filtro o la página actual de un listado son el ejemplo típico.
  3. ¿Es una copia de datos del servidor que debe actualizarse sola en el navegador (cada pocos segundos, al volver a la pestaña, con scroll infinito)? Va a TanStack Query, precargado desde el servidor.
  4. ¿Lo usan componentes alejados entre sí, solo en el navegador? Va a un almacén global: Zustand o Context.
  5. Si no: estado local con useState.

El resto del módulo recorre cada sitio con la aplicación EMPRESA, midiendo lo que pasa por la red en cada caso.


2. El laboratorio del módulo

Todo lo que se cuenta aquí se ha comprobado con un proyecto Next.js 16.4 (create-next-app con Cache Components activado) contra la API EMPRESA, y las alternativas para Next.js 15 con un proyecto 15.5. A partir del módulo 16 el curso usa una base de datos SQLite propia; en este módulo leemos directamente de la API para que se pueda seguir sin haber hecho los anteriores. Si ya tienes la capa de datos del módulo 16, sustituye las funciones de lib/api.ts por las tuyas: nada más cambia.

Arranca la API EMPRESA (recursos/empresa-api.zip) en una terminal:

node server.mjs

Queda en http://localhost:3000/api. Next.js también usa el puerto 3000 por defecto, así que arráncalo en otro, por ejemplo con next dev -p 3001. Crea un .env.local con dos variables:

# .env.local
API_URL=http://localhost:3000/api
NEXT_PUBLIC_API_URL=http://localhost:3000/api

La primera la usan los Server Components y las Server Actions. La segunda lleva el prefijo NEXT_PUBLIC_ porque la usan los Client Components con TanStack Query, que piden datos desde el navegador; Next incrusta su valor en el JavaScript del cliente al compilar (módulo 13). La API EMPRESA responde con Access-Control-Allow-Origin: *, así que el navegador puede llamarla directamente desde otro puerto.

Zustand no viene con create-next-app. Instálalo:

npm install zustand @tanstack/react-query

En el laboratorio se usaron Zustand 5.0.16 y TanStack Query 5.105.1.

La estructura del proyecto:

app/
├─ layout.tsx                 cabecera con enlaces e insignia del carrito
├─ providers.tsx              QueryClientProvider y rehidratación del carrito
├─ insignia-carrito.tsx       lee el carrito (Zustand)
├─ tienda/
│  ├─ layout.tsx              VistaProvider (Context)
│  ├─ vista.tsx               Context de la vista tarjetas/lista
│  ├─ page.tsx                catálogo: searchParams en el servidor
│  ├─ filtros.tsx             useSearchParams + router.replace
│  ├─ boton-anadir.tsx        añade al carrito (Zustand)
│  └─ carrito/page.tsx        contenido del carrito
└─ gestion/
   ├─ pedidos/                TanStack Query precargado + Server Action
   ├─ empleados/              TanStack Query solo en el cliente + History API
   └─ resumen/                TanStack Query con datos prerenderizados
lib/
├─ tipos.ts                   tipos de la API
├─ filtros.ts                 leer y escribir los filtros de la URL
├─ api.ts                     funciones de servidor con 'use cache'
├─ consultas.ts               contrato de caché de TanStack Query
├─ hidratar.ts                deshidratación compatible con Cache Components
└─ carrito.ts                 store de Zustand con persist

Los tipos de la API, que usan casi todos los ficheros:

// lib/tipos.ts
export type Pagina<T> = {
  contenido: T[]
  pagina: number
  tamano: number
  total: number
  paginas: number
}

export type Producto = {
  productoId: number
  nombre: string
  descripcion: string
  categoriaId: number
  categoria: string
  precio: number
  stock: number
  descatalogado: boolean
}

export type Categoria = { categoriaId: number; nombre: string; categoriaPadreId: number | null }

export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO'

export type Pedido = {
  pedidoId: number
  cliente: string
  fechaPedido: string
  estado: EstadoPedido
  total: number
}

export type Empleado = {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
  salario: number
  departamento: string | null
}

export type Estadisticas = {
  empleados: number
  clientes: number
  productos: number
  pedidos: number
  facturacion: number
}

Y las funciones de servidor. Todas usan 'use cache' con una vida y una etiqueta, como aprendiste en el módulo 09:

// lib/api.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import type { Categoria, Estadisticas, Pagina, Pedido, Producto } from './tipos'
import type { FiltrosCatalogo } from './filtros'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

async function pedir<T>(ruta: string): Promise<T> {
  const respuesta = await fetch(`${API}${ruta}`)
  if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status} en ${ruta}`)
  return respuesta.json() as Promise<T>
}

export async function obtenerCategorias(): Promise<Categoria[]> {
  'use cache'
  cacheLife('days')
  cacheTag('categorias')
  const datos = await pedir<Pagina<Categoria>>('/categorias?tamano=100')
  return datos.contenido
}

// Una entrada de caché por cada combinación de filtros: los argumentos forman parte de la clave
export async function buscarProductos(f: FiltrosCatalogo): Promise<Pagina<Producto>> {
  'use cache'
  cacheLife('minutes')
  cacheTag('productos')
  const p = new URLSearchParams({ tamano: '6', pagina: String(f.pagina), orden: f.orden })
  if (f.q) p.set('q', f.q)
  if (f.categoria) p.set('categoriaId', String(f.categoria))
  return pedir<Pagina<Producto>>(`/productos?${p}`)
}

export async function obtenerPedidos(estado: string | null): Promise<Pedido[]> {
  'use cache'
  cacheLife('seconds')
  cacheTag('pedidos')
  const datos = await pedir<Pagina<Pedido>>(`/pedidos?tamano=100${estado ? `&estado=${estado}` : ''}`)
  return datos.contenido
}

export async function obtenerEstadisticas(): Promise<Estadisticas> {
  'use cache'
  cacheLife('max')
  cacheTag('estadisticas')
  return pedir<Estadisticas>('/estadisticas')
}

Para medir, se arrancó la API con el envoltorio api-registro.mjs del módulo 09, que escribe una línea por cada petición recibida, y se recorrió la aplicación con un script de Playwright que anota las peticiones del navegador: las de datos a la API y las peticiones RSC de Next (las que llevan la cabecera RSC: 1, con las que el enrutador pide al servidor la nueva interfaz al navegar).


3. La URL como estado: searchParams en el servidor 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 searchParams ya es una promesa, pero todavía se puede leer de forma síncrona (con un aviso); en 16 solo se puede esperar con await o use. Si usas Next.js 15: escribe el mismo código con await searchParams, que funciona igual; para guardar en caché las consultas usa el modelo de caché de Next.js 15 del módulo 09 (apartado 14) en lugar de 'use cache'.

La cadena de consulta (?q=monitor&orden=-precio) es el mejor sitio para cualquier estado que describa qué está viendo el usuario: una búsqueda, unos filtros, una página de resultados, una pestaña. Tiene ventajas que ningún almacén iguala:

  • Se comparte. Copias el enlace, lo pegas en un correo y tu compañero ve exactamente lo mismo.
  • Sobrevive. A recargar la página, a cerrar la pestaña y volver desde el historial, a guardarlo en favoritos.
  • Funciona con Atrás y Adelante del navegador, si eliges bien entre push y replace (apartado 4.3).
  • La lee el servidor. El Server Component recibe los filtros y renderiza el resultado correcto desde la primera petición, sin esperar a que el JavaScript se descargue.

En el módulo 04 viste que las páginas reciben searchParams como una promesa. Aquí lo convertimos en el estado del catálogo de la tienda.

3.1 Leer la URL es leer datos del usuario

La URL la escribe quien quiera. Alguien puede visitar /tienda?pagina=-3&orden=DROP&categoria=abc, y tu página no debe romperse ni mandar esos valores a la API. Por eso conviene tener un único módulo que sepa leer los filtros de la URL, validarlos y escribirlos de vuelta:

// lib/filtros.ts
// Un solo sitio que sabe leer y escribir los filtros del catálogo en la URL.
// No importa nada del servidor ni del cliente: lo usan los dos.

export const ORDENES = ['nombre', 'precio', '-precio'] as const
export type Orden = (typeof ORDENES)[number]

export type FiltrosCatalogo = {
  q: string
  categoria: number | null
  orden: Orden
  pagina: number
}

type Entrada = Record<string, string | string[] | undefined> | URLSearchParams

function leer(entrada: Entrada, clave: string): string | undefined {
  if (entrada instanceof URLSearchParams) return entrada.get(clave) ?? undefined
  const valor = entrada[clave]
  return Array.isArray(valor) ? valor[0] : valor
}

export function leerFiltros(entrada: Entrada): FiltrosCatalogo {
  const orden = leer(entrada, 'orden')
  const categoria = Number(leer(entrada, 'categoria'))
  const pagina = Number(leer(entrada, 'pagina'))
  return {
    q: (leer(entrada, 'q') ?? '').trim().slice(0, 50),
    categoria: Number.isInteger(categoria) && categoria > 0 ? categoria : null,
    orden: ORDENES.includes(orden as Orden) ? (orden as Orden) : 'nombre',
    pagina: Number.isInteger(pagina) && pagina > 0 ? pagina : 1,
  }
}

// Devuelve la cadena de consulta sin los valores por defecto: URLs cortas y una sola URL por estado
export function escribirFiltros(f: Partial<FiltrosCatalogo>): string {
  const p = new URLSearchParams()
  if (f.q) p.set('q', f.q)
  if (f.categoria) p.set('categoria', String(f.categoria))
  if (f.orden && f.orden !== 'nombre') p.set('orden', f.orden)
  if (f.pagina && f.pagina > 1) p.set('pagina', String(f.pagina))
  const texto = p.toString()
  return texto ? `?${texto}` : ''
}

Tres detalles importan:

  • leerFiltros acepta los dos formatos. En el servidor, searchParams es un objeto cuyos valores pueden ser string, string[] (si el parámetro se repite: ?q=a&q=b) o undefined. En el cliente, useSearchParams devuelve un URLSearchParams de solo lectura. La misma función sirve para los dos, así que el servidor y el cliente nunca discrepan sobre qué filtro está activo.
  • Todo valor no válido cae al valor por defecto. pagina=-3 es la página 1, orden=DROP es por nombre. El usuario no ve un error por una URL mal escrita.
  • escribirFiltros omite los valores por defecto. /tienda y /tienda?orden=nombre&pagina=1 muestran lo mismo, pero si permites las dos formas tendrás dos URLs (y dos entradas de caché) para el mismo contenido. Con una sola forma canónica, la caché se aprovecha mejor y los enlaces quedan limpios.

⚠️ Importante: Nunca construyas una consulta a la base de datos ni una URL de la API pegando lo que llega en searchParams. Valídalo primero, como hace leerFiltros, o con un esquema de Zod (módulo 11).

3.2 La página: pasar la promesa hacia abajo

Con Cache Components, searchParams es un dato de petición: no existe en el build. Si la página hace await searchParams arriba del todo, toda la página pasa a depender de la petición y el build falla, aunque el contenido esté dentro de <Suspense> (lo comprobaste en el módulo 10). La solución es la de siempre: la página no espera nada, y pasa la promesa al componente que la necesita, dentro de un <Suspense>:

// app/tienda/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { buscarProductos, obtenerCategorias } from '@/lib/api'
import { escribirFiltros, leerFiltros } from '@/lib/filtros'
import { Filtros, FiltrosEsqueleto } from './filtros'
import { ContenedorVista, SelectorVista } from './vista'
import { BotonAnadir } from './boton-anadir'

// La página no espera a searchParams: pasa la promesa hacia abajo, dentro de <Suspense>
export default function PaginaTienda({ searchParams }: PageProps<'/tienda'>) {
  return (
    <>
      <div className="mb-4 flex items-center justify-between">
        <h1 className="text-2xl font-bold">Tienda</h1>
        <SelectorVista />
      </div>
      <Suspense fallback={<FiltrosEsqueleto />}>
        <FiltrosConCategorias />
      </Suspense>
      <Suspense fallback={<p>Cargando productos…</p>}>
        <Resultados busqueda={searchParams} />
      </Suspense>
    </>
  )
}

async function FiltrosConCategorias() {
  const categorias = await obtenerCategorias()
  return <Filtros categorias={categorias} />
}

async function Resultados({ busqueda }: { busqueda: PageProps<'/tienda'>['searchParams'] }) {
  const filtros = leerFiltros(await busqueda)
  const { contenido, pagina, paginas, total } = await buscarProductos(filtros)

  if (total === 0) return <p>No hay productos con esos filtros.</p>

  return (
    <>
      <p className="mb-2 text-sm text-slate-500" data-testid="total">
        {total} productos · página {pagina} de {paginas}
      </p>
      <ContenedorVista>
        {contenido.map((p) => (
          <li key={p.productoId} className="rounded border bg-white p-3">
            <p className="font-semibold">{p.nombre}</p>
            <p className="text-sm text-slate-500">{p.categoria}</p>
            <p className="my-1">{p.precio.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}</p>
            <BotonAnadir producto={{ productoId: p.productoId, nombre: p.nombre, precio: p.precio }} />
          </li>
        ))}
      </ContenedorVista>
      <nav className="mt-4 flex gap-2" aria-label="Páginas">
        {Array.from({ length: paginas }, (_, i) => i + 1).map((n) => (
          <Link
            key={n}
            href={`/tienda${escribirFiltros({ ...filtros, pagina: n })}`}
            scroll={false}
            aria-current={n === pagina ? 'page' : undefined}
            className={`rounded px-3 py-1 ${n === pagina ? 'bg-indigo-700 text-white' : 'bg-slate-200'}`}
          >
            {n}
          </Link>
        ))}
      </nav>
    </>
  )
}

Fíjate en el tipo de la prop: PageProps<'/tienda'>['searchParams'] es Promise<Record<string, string | string[] | undefined>>. Los tipos globales PageProps y LayoutProps los genera Next a partir de tus rutas (módulo 04).

El build lo confirma. La tienda es una página ◐ (Partial Prerender): el título, el selector de vista y los dos esqueletos forman el armazón estático; los filtros y los resultados son huecos que se rellenan en cada petición. La vida de 1 día viene de obtenerCategorias (cacheLife('days')), la más corta de lo que hay en el armazón:

Route (app)                Revalidate  Expire
┌ ○ /
├ ○ /gestion/empleados
├ ◐ /gestion/pedidos
├ ○ /gestion/resumen              30d      1y
├ ◐ /tienda                        1d      1w
└ ○ /tienda/carrito

3.3 Una entrada de caché por combinación de filtros

buscarProductos recibe el objeto de filtros como argumento. Con 'use cache', los argumentos forman parte de la clave de caché: cada combinación distinta de búsqueda, categoría, orden y página tiene su propia entrada, que vive los minutos que indica cacheLife('minutes').

El registro de la API durante el recorrido de la tienda lo muestra. Cada combinación nueva llega una sola vez a la API; volver a una combinación ya vista no genera ninguna petición:

[API 13:20:35.637] GET /api/productos?tamano=6&pagina=1&orden=nombre
[API 13:20:38.509] GET /api/productos?tamano=6&pagina=1&orden=nombre&q=monitor
[API 13:20:40.490] GET /api/productos?tamano=6&pagina=1&orden=-precio&q=monitor
[API 13:20:42.071] GET /api/productos?tamano=6&pagina=1&orden=-precio
[API 13:20:44.161] GET /api/productos?tamano=6&pagina=2&orden=-precio
[API 13:20:47.900] GET /api/productos?tamano=6&pagina=1&orden=-precio&categoriaId=2

Aquí se ve la ventaja de normalizar la URL con leerFiltros: el objeto que llega a buscarProductos es siempre canónico (pagina numérica, orden válido, q recortada), así que ?pagina=01 y ?pagina=1 comparten entrada de caché.

💡 Consejo: Pasa a la función cacheada valores simples y ya validados, no el objeto searchParams entero. Si le pasas el objeto tal cual, cualquier parámetro extra (?utm_source=correo) crea una entrada de caché nueva con el mismo contenido.

3.4 Enlaces que cambian la URL

Para la paginación no hace falta JavaScript propio: son enlaces. <Link> con un href que incluye la cadena de consulta navega a la misma ruta con otros parámetros. Next pide al servidor solo el hueco dinámico (el armazón ya está en pantalla) y el layout no se vuelve a montar.

1 enlace pagina 2: /tienda?orden=-precio&pagina=2 | 20 productos · página 2 de 4 [ 'RSC /tienda?orden=-precio&pagina=2' ]

Una petición RSC y nada más. Dos props de <Link> del módulo 03 son útiles con estado en la URL:

  • scroll={false}: por defecto, Next desplaza la ventana arriba al navegar. En un listado con filtros, el usuario suele querer quedarse donde estaba.
  • replace: sustituye la entrada actual del historial en lugar de añadir una nueva (apartado 4.3).

href también admite un objeto. Con { query: { pestana: 'cliente' } } y sin pathname, el enlace va a la misma ruta con esa cadena de consulta. Lo usarás en el ejercicio 2.

3.5 Formularios GET con <Form> y lo que pasa sin JavaScript

Un formulario HTML con method="get" ya es una forma de escribir en la URL: al enviarlo, el navegador va a action?campo=valor&.... Next ofrece el componente <Form> de next/form, que hace lo mismo con navegación del cliente: en vez de recargar la página entera, navega como un <Link>, y además precarga la ruta de destino cuando el formulario aparece en pantalla.

import Form from 'next/form'

<Form action="/tienda" replace scroll={false}>
  <input name="q" />
  <button type="submit">Buscar</button>
</Form>

Con una cadena en action, <Form> construye la URL a partir de los campos (/tienda?q=...). Admite replace y scroll como <Link>. Si action es una función, se comporta como un formulario de Server Action (módulo 10).

Se suele presentar <Form> como una mejora progresiva: sin JavaScript, sigue siendo un formulario GET normal. Es verdad para el formulario, pero no para lo que se pinta en los huecos dinámicos de una página ◐. Se abrió /tienda?q=raton con JavaScript desactivado:

8 sin JS: total visible false | total en el HTML true | fallback visible true | campo visible false

La tienda sin JavaScript: se ve la cabecera, el título y el selector de vista, pero los filtros son un rectángulo gris y los resultados siguen en "Cargando productos..."

Figura 2. La tienda con JavaScript desactivado. El servidor envió los resultados (están en el HTML), pero nunca se muestran.

Los resultados están en el HTML, pero no se ven. Con streaming, React envía primero el armazón con los fallbacks y después el contenido de cada <Suspense> en un bloque oculto, acompañado de un pequeño <script> que lo coloca en su sitio. Sin JavaScript, ese script no se ejecuta y el usuario se queda mirando “Cargando productos…”. Es la misma familia de problema que viste en el módulo 08 con formularios en páginas ◐.

📘 Concepto: Con Cache Components, todo lo que depende de searchParams vive en un hueco dinámico y necesita JavaScript para mostrarse. Si una página tiene que funcionar sin JavaScript (un buscador público muy básico, por ejemplo), la información debe estar en el armazón o la página debe renderizarse entera antes de enviarse. Para la inmensa mayoría de aplicaciones, que ya dependen de JavaScript, no es un problema; pero no prometas mejora progresiva sin haberla probado con JavaScript desactivado.


4. useSearchParams en el cliente

Los filtros del catálogo son un Client Component: el usuario escribe en un campo, elige una categoría en un desplegable, y la URL debe cambiar sin recargar. Para eso hay que leer la cadena de consulta desde el cliente y escribir una nueva.

4.1 Leer: useSearchParams

useSearchParams de next/navigation devuelve un ReadonlyURLSearchParams: la interfaz de URLSearchParams del navegador sin los métodos que modifican (set, delete, append). Se actualiza solo cada vez que cambia la URL, por cualquier vía: un <Link>, router.push, el botón Atrás o la History API (apartado 4.7).

'use client'
import { useSearchParams } from 'next/navigation'

const parametros = useSearchParams()
parametros.get('q')          // 'monitor' o null
parametros.getAll('tag')     // ['a', 'b'] para ?tag=a&tag=b
parametros.has('pagina')     // true o false
parametros.toString()        // 'q=monitor&orden=-precio'

Para modificarla, copia en un URLSearchParams normal, cambia lo que necesites y conviértelo en texto:

const nuevos = new URLSearchParams(parametros)
nuevos.set('pagina', '2')
nuevos.delete('q')
router.replace(`${ruta}?${nuevos}`)

4.2 Escribir: el componente de filtros

Este es el componente completo. Lee los filtros con la misma leerFiltros del servidor, y escribe con router.replace dentro de una transición:

// app/tienda/filtros.tsx
'use client'

import Form from 'next/form'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useTransition } from 'react'
import { escribirFiltros, leerFiltros, type FiltrosCatalogo } from '@/lib/filtros'
import type { Categoria } from '@/lib/tipos'

export function Filtros({ categorias }: { categorias: Categoria[] }) {
  const parametros = useSearchParams()
  const ruta = usePathname()
  const router = useRouter()
  const [pendiente, iniciarTransicion] = useTransition()
  const filtros = leerFiltros(parametros)
  const campoTexto = useRef<HTMLInputElement>(null)
  const temporizador = useRef<ReturnType<typeof setTimeout>>(undefined)

  // Si la URL cambia por otro motivo (Atrás, un enlace), el campo de texto se pone al día
  useEffect(() => {
    if (campoTexto.current && document.activeElement !== campoTexto.current) {
      campoTexto.current.value = filtros.q
    }
  }, [filtros.q])

  function navegar(cambios: Partial<FiltrosCatalogo>) {
    // Cualquier cambio de filtro vuelve a la página 1
    const url = ruta + escribirFiltros({ ...filtros, pagina: 1, ...cambios })
    iniciarTransicion(() => router.replace(url, { scroll: false }))
  }

  function alEscribir(texto: string) {
    clearTimeout(temporizador.current)
    temporizador.current = setTimeout(() => navegar({ q: texto }), 300)
  }

  return (
    // Sin JavaScript, <Form> es un formulario GET normal; con JavaScript, navega sin recargar
    <Form action="/tienda" replace scroll={false} className="mb-4 flex flex-wrap items-center gap-3">
      <input
        ref={campoTexto}
        name="q"
        type="search"
        placeholder="Buscar producto"
        defaultValue={filtros.q}
        onChange={(e) => alEscribir(e.target.value)}
        className="rounded border px-2 py-1"
      />
      <div key={parametros.toString()} className="flex gap-3">
        <select
          name="categoria"
          defaultValue={filtros.categoria ?? ''}
          onChange={(e) => navegar({ categoria: Number(e.target.value) || null })}
          className="rounded border px-2 py-1"
        >
          <option value="">Todas las categorías</option>
          {categorias.map((c) => (
            <option key={c.categoriaId} value={c.categoriaId}>
              {c.nombre}
            </option>
          ))}
        </select>
        <select
          name="orden"
          defaultValue={filtros.orden}
          onChange={(e) => navegar({ orden: e.target.value as FiltrosCatalogo['orden'] })}
          className="rounded border px-2 py-1"
        >
          <option value="nombre">Nombre</option>
          <option value="precio">Precio ascendente</option>
          <option value="-precio">Precio descendente</option>
        </select>
      </div>
      <noscript>
        <button type="submit">Filtrar</button>
      </noscript>
      {pendiente && <span className="text-sm text-indigo-700" data-testid="pendiente">Actualizando…</span>}
    </Form>
  )
}

export function FiltrosEsqueleto() {
  return <div className="mb-4 h-8 w-96 animate-pulse rounded bg-slate-200" />
}

Repasemos las decisiones, porque cada una evita un problema real:

  • Espera de 300 ms al escribir (debounce). Sin ella, cada tecla sería una navegación y una petición al servidor. Se escribió “monitor” tecla a tecla, con 70 ms entre pulsaciones, y el script contó las peticiones:

    1 tras escribir monitor: /tienda?q=monitor | 2 productos · página 1 de 1
    1 peticiones al escribir: [ 'RSC /tienda?q=monitor' ]
    

    Siete teclas, una petición RSC.

  • useTransition. router.replace es una navegación, y las navegaciones de Next son transiciones de React: mientras llega la respuesta, React mantiene en pantalla la interfaz anterior en lugar de volver a mostrar el fallback de <Suspense>. Envolverla en iniciarTransicion nos da pendiente, para mostrar “Actualizando…” sin que la lista parpadee. Es el comportamiento de las transiciones que viste en el Curso de React.
  • Volver a la página 1 al cambiar cualquier filtro. Si estabas en la página 3 de todos los productos y buscas “monitor”, que solo tiene una página, la página 3 no existiría.
  • scroll: false en router.replace, por la misma razón que en los enlaces.

Al elegir “Precio descendente”, la URL y la lista cambian con una sola petición:

1 tras ordenar: /tienda?q=monitor&orden=-precio | Monitor 27 4K / Monitor 24 FHD
1 peticiones al ordenar: [ 'RSC /tienda?q=monitor&orden=-precio' ]

La tienda con la categoría Portátiles y el orden Precio descendente: tres tarjetas de portátiles de 1499, 899 y 499 euros, y la insignia Carrito: 2 en la cabecera

Figura 3. La tienda con los filtros ?categoria=2&orden=-precio. Los desplegables reflejan la URL; la insignia del carrito viene de Zustand (apartado 7).

4.3 push o replace

Las dos cambian la URL y provocan la misma petición RSC. La diferencia está en el historial del navegador:

Método Historial Atrás lleva a Úsalo para
router.push(url) Añade una entrada La URL anterior Cambios que el usuario querrá deshacer con Atrás: pasar de página, abrir una pestaña
router.replace(url) Sustituye la entrada actual La página de antes de llegar aquí Cambios continuos: escribir en un buscador, mover un control deslizante

Se comprobó con una secuencia concreta: abrir /tienda?orden=-precio, pulsar el enlace de la página 2 (que es un push) y después elegir orden por nombre en el desplegable (que es un replace):

1 enlace pagina 2: /tienda?orden=-precio&pagina=2 | 20 productos · página 2 de 4
1 orden por nombre (replace): /tienda | 20 productos · página 1 de 4
1 tras Atras: /tienda?orden=-precio | orden select = -precio | 20 productos · página 1 de 4

Atrás no vuelve a la página 2: replace sustituyó esa entrada del historial por /tienda, así que la anterior es /tienda?orden=-precio. Para un buscador es lo que quieres: nadie quiere pulsar Atrás siete veces para deshacer siete letras. Para la paginación, en cambio, push es lo natural, y por eso los enlaces de página no llevan replace.

4.4 Mantener los campos al día

Los campos del formulario son no controlados (defaultValue): el navegador guarda lo que escribes y React solo pone el valor inicial. Eso evita volver a renderizar en cada tecla, pero tiene un coste: si la URL cambia por otra vía (el botón Atrás de antes, un enlace “Limpiar filtros”), defaultValue no se vuelve a aplicar y el desplegable seguiría mostrando el orden viejo.

El componente lo resuelve de dos maneras:

  • Los desplegables están dentro de un <div key={parametros.toString()}>. Cuando la cadena de consulta cambia, la key cambia y React vuelve a crear los <select> con el defaultValue nuevo. La prueba de Atrás lo confirma: orden select = -precio.
  • El campo de texto no puede usar el mismo truco: mientras escribes, tu propia escritura cambia la URL, la key cambiaría y el campo se volvería a crear perdiendo el foco a mitad de palabra. Por eso se pone al día con un efecto, y solo si el usuario no está escribiendo en él (document.activeElement).

Otra opción es usar campos controlados con useState y sincronizarlos con la URL, pero entonces tienes dos fuentes de verdad (el estado y la URL) y debes decidir cuál manda en cada momento. Con campos no controlados, la URL es la única fuente de verdad.

4.5 useSearchParams necesita <Suspense> 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 la regla es la misma (una página estática que usa useSearchParams sin <Suspense> no compila), pero el error se llama “Missing Suspense boundary with useSearchParams” y, en páginas dinámicas, no se exige. Si usas Next.js 15: envuelve igualmente el componente en <Suspense>; el arreglo es idéntico y no hay nada más que cambiar.

Durante el build, Next prerenderiza el armazón de cada página, y en ese momento no hay URL: no existe ninguna cadena de consulta que leer. Un Client Component que llama a useSearchParams no puede renderizarse en el build. Con Cache Components, si no hay un <Suspense> por encima que diga qué mostrar mientras tanto, el build falla. Se comprobó con esta página:

// app/errores/sin-suspense/page.tsx
'use client'

import { useSearchParams } from 'next/navigation'

export default function Pagina() {
  const q = useSearchParams().get('q')
  return <p>Buscas: {q}</p>
}
Error: Route "/errores/sin-suspense": Next.js encountered URL data `useSearchParams()` in a Client Component outside of `<Suspense>`.
This blocks prerendering because the value is only available at runtime.
Ways to fix this:
  - [stream] Wrap the component in `<Suspense fallback={...}>` so the hook value streams in after prerendering
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-client-hook
    at <unknown> (app\errores\sin-suspense\page.tsx:6:13)
...
Export encountered an error on /errores/sin-suspense/page: /errores/sin-suspense, exiting the build.

El mensaje ofrece dos salidas:

  • [stream]: envolver en <Suspense>. Es lo correcto casi siempre, y es lo que hace la tienda: <Suspense fallback={<FiltrosEsqueleto />}>. El armazón incluye el esqueleto; los filtros de verdad se renderizan en la petición.
  • [block]: export const instant = false. Desactiva la exigencia de navegación instantánea para esa ruta (módulo 15, solo existe desde Next.js 16.3). La página entera esperará a la petición. Solo tiene sentido si de verdad no puede haber un armazón útil.

El mismo error en Next.js 15.5, con el mismo fichero:

⨯ useSearchParams() should be wrapped in a suspense boundary at page "/sin-suspense". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
Error occurred prerendering page "/sin-suspense". Read more: https://nextjs.org/docs/messages/prerender-error

⚠️ Importante: En next dev este error no aparece: en desarrollo las páginas se renderizan bajo demanda, siempre con una URL real. Lo descubrirás al compilar. Ejecuta next build a menudo, no solo antes de desplegar.

Una consecuencia de diseño: pon el <Suspense> lo más abajo posible, alrededor del componente que lee la URL y no de la página entera. Todo lo que quede por encima (la cabecera, el título, el selector de vista) forma parte del armazón estático y aparece al instante. En la tienda, los dos <Suspense> dejan fuera el título y el selector de vista.

4.6 Tres formas de leer la cadena de consulta

Dónde Cómo Cuándo se actualiza Exige <Suspense>
Server Component (página) searchParams (prop, promesa) En cada navegación, con una petición RSC Sí, si se espera dentro de la página con Cache Components
Client Component useSearchParams() Con cualquier cambio de URL, incluida la History API Sí, para poder prerenderizar
Client Component que recibe la promesa use(searchParams) con la prop pasada desde la página Como la prop del servidor Sí: use suspende

¿Cuál elegir? Si el dato decide qué datos se piden (los productos de una categoría), léelo en el servidor: la primera respuesta ya trae el resultado correcto. Si solo decide cómo se muestra algo en el cliente (qué columna está ordenada en una tabla que ya tienes entera), léelo con useSearchParams y cambia la URL sin ir al servidor, como se ve ahora.

4.7 Cambiar la URL sin ir al servidor: la History API

router.push y router.replace siempre piden al servidor la nueva interfaz. A veces no hace falta: la tabla de empleados ya tiene todos los empleados en el navegador, y ordenarlos por salario es un cálculo local. Queremos que el orden quede en la URL (para compartirlo y para que Atrás lo deshaga), pero sin petición.

Next se integra con los métodos nativos window.history.pushState y window.history.replaceState. Cuando los llamas, el enrutador de Next se sincroniza: useSearchParams y usePathname devuelven los valores nuevos, y los componentes cliente que los usan vuelven a renderizar. El servidor no se entera.

Dos columnas: a la izquierda, router.replace provoca una petición RSC, el servidor renderiza y se actualizan servidor y cliente; a la derecha, history.replaceState no hace ninguna petición, Next sincroniza useSearchParams y solo se renderizan los Client Components

Figura 4. Las dos formas de cambiar la cadena de consulta. La de la derecha no pasa por el servidor, así que los Server Components no ven el cambio.

La tabla de empleados lo usa para ordenar:

// app/gestion/empleados/tabla-empleados.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { empleadosOpciones } from '@/lib/consultas'
import type { Empleado } from '@/lib/tipos'

const COLUMNAS = [
  ['apellidos', 'Apellidos'],
  ['oficio', 'Oficio'],
  ['salario', 'Salario'],
] as const
type Columna = (typeof COLUMNAS)[number][0]

export function TablaEmpleados() {
  const parametros = useSearchParams()
  const orden = (parametros.get('orden') ?? 'apellidos') as Columna
  const [texto, setTexto] = useState('') // estado local: no va a la URL
  const { data = [], isPending, isFetching } = useQuery(empleadosOpciones())

  useEffect(() => {
    console.log('[Empleados] efecto montado')
    return () => console.log('[Empleados] efecto limpiado')
  }, [])

  function ordenar(columna: Columna) {
    const p = new URLSearchParams(parametros)
    p.set('orden', columna)
    // pushState: crea una entrada en el historial (Atrás deshace el orden) sin ir al servidor
    window.history.pushState(null, '', `?${p}`)
  }

  const filas = data
    .filter((e) => `${e.nombre} ${e.apellidos}`.toLowerCase().includes(texto.toLowerCase()))
    .toSorted((a: Empleado, b: Empleado) =>
      orden === 'salario' ? b.salario - a.salario : a[orden].localeCompare(b[orden], 'es'),
    )

  if (isPending) return <p>Cargando empleados…</p>

  return (
    <>
      <div className="mb-3 flex items-center gap-3">
        <input
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
          placeholder="Filtrar por nombre"
          className="rounded border px-2 py-1"
          data-testid="filtro"
        />
        {isFetching && <span className="text-sm text-indigo-700">Actualizando…</span>}
      </div>
      <table className="w-full bg-white text-left text-sm">
        <thead>
          <tr className="border-b bg-slate-100">
            {COLUMNAS.map(([clave, titulo]) => (
              <th key={clave} className="p-2">
                <button onClick={() => ordenar(clave)} className={orden === clave ? 'font-bold underline' : ''}>
                  {titulo}
                </button>
              </th>
            ))}
          </tr>
        </thead>
        <tbody data-testid="filas">
          {filas.map((e) => (
            <tr key={e.empId} className="border-b">
              <td className="p-2">{e.apellidos}, {e.nombre}</td>
              <td className="p-2">{e.oficio}</td>
              <td className="p-2">{e.salario.toLocaleString('es-ES')} €</td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  )
}
// app/gestion/empleados/page.tsx
import { Suspense } from 'react'
import { TablaEmpleados } from './tabla-empleados'

export default function PaginaEmpleados() {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold">Empleados</h1>
      <Suspense fallback={<p>Preparando tabla…</p>}>
        <TablaEmpleados />
      </Suspense>
    </>
  )
}

Esta página compila como ○ (Static): no lee searchParams en el servidor, así que el servidor no tiene nada que hacer en cada petición. El HTML estático lleva “Preparando tabla…”, y el navegador hace el resto. Al pulsar “Salario”:

5 URL tras ordenar: /gestion/empleados?orden=salario | filas: 10

La URL cambió y la tabla se reordenó sin ninguna petición RSC ni a la API.

⚠️ Importante: Con la History API, los Server Components no se vuelven a renderizar. Si un Server Component de esa página lee searchParams, seguirá mostrando el valor viejo hasta la siguiente navegación de verdad. Úsala solo para estado que lean Client Components. Y pasa siempre null o un objeto propio como primer argumento: Next guarda su propia información en history.state, y la conserva por ti al sincronizarse.


5. El estado local y <Activity> al navegar 🆕 16.0

🆕 Desde Next.js 16.0. No existe en Next.js 15: al salir de una ruta, sus componentes se desmontan y su useState se pierde. Si usas Next.js 15: guarda en la URL (apartado 3) o en un almacén global (apartados 6 y 7) el estado que deba sobrevivir a una navegación.

En el módulo 03 (apartado 9) viste que, con Cache Components, Next.js no desmonta las rutas que abandonas: las oculta con el componente <Activity> de React, hasta un límite de rutas guardadas. Su estado de React y su DOM se conservan; sus efectos se limpian y se vuelven a ejecutar al volver. Ahora que tienes tres sitios para el estado (local, URL, caché del cliente), conviene ver cómo se comporta cada uno con esta novedad.

La prueba, sobre la tabla de empleados:

  1. Escribir “ez” en el filtro (estado local, useState).
  2. Ordenar por salario (URL, con pushState).
  3. Ir a la tienda con el menú.
  4. Volver a Empleados con el menú (un <Link href="/gestion/empleados">, sin cadena de consulta).
  5. Pulsar Atrás dos veces.
5 al salir: [ '[log] [Empleados] efecto limpiado' ] [ 'RSC /tienda' ]
5 al volver con Link: URL /gestion/empleados | filtro = ez | filas 10 | primera: Castro Méndez, Iván
5 consola y peticiones al volver: [ '[log] [Empleados] efecto montado' ] [ 'API /api/empleados?tamano=100' ]
5 Atras x2: URL /gestion/empleados?orden=salario | filtro = ez | primera: García López, Ana

Cada línea cuenta algo:

  • Al salir, el efecto se limpia, como si el componente se desmontara. Pero no se desmonta.
  • Al volver con el menú, el filtro “ez” sigue ahí: <Activity> conservó el useState. En cambio, el orden se ha perdido: el enlace del menú apunta a /gestion/empleados, sin ?orden=salario, y la URL manda. La tabla vuelve a estar ordenada por apellidos (Castro Méndez primero).
  • TanStack Query vuelve a pedir los empleados. Al ocultarse la ruta, el componente dejó de estar suscrito a la consulta (las suscripciones viven en efectos). Al volver, se suscribe otra vez, ve que los datos están caducados (staleTime por defecto es 0) y los pide de nuevo. Con un staleTime mayor no habría petición.
  • Con Atrás se vuelve a la URL con ?orden=salario, y el filtro sigue siendo “ez”. Ana García López, la de mayor salario, aparece la primera.
Situación useState (filtro) URL (orden) Caché de TanStack Query
Volver con un <Link> sin parámetros Se conserva (16) / se pierde (15) Se pierde: manda el enlace Se conserva; se vuelve a pedir si está caducada
Atrás o Adelante del navegador Se conserva (16) / se pierde (15) Se recupera del historial Igual
Recargar la página (F5) Se pierde Se conserva Se pierde
Copiar el enlace a otra pestaña Se pierde Se conserva Se pierde
Visitar varias rutas más (límite de <Activity>) Se pierde Lo que diga el enlace Se conserva

💡 Consejo: <Activity> es una mejora de la experiencia, no un almacenamiento. Si un estado debe sobrevivir, decide tú dónde: URL si se debe compartir, persist o cookie si debe sobrevivir a recargas, servidor si importa de verdad. Si un estado no debe sobrevivir (un formulario enviado que debe aparecer vacío al volver), el módulo 03 explica cómo reiniciarlo con una key o con bfcacheId.


6. Context en Next.js

Context es la forma que trae React de compartir un valor con todo un subárbol sin pasarlo por props. En Next.js tiene una limitación y un patrón que hay que conocer.

6.1 La limitación: solo en Client Components

createContext, use(Contexto) y useContext son APIs de cliente. Un Server Component no puede leer un contexto, y un fichero que llama a createContext necesita 'use client'. Para compartir datos entre Server Components no se usa Context: cada uno pide lo que necesita, y cache de React o 'use cache' evitan repetir el trabajo (módulo 08).

6.2 El patrón: un proveedor cliente que envuelve hijos del servidor

El selector de vista de la tienda (tarjetas o lista) es un buen ejemplo de estado global pequeño: lo cambia un botón de la cabecera de la página y lo usa la lista de productos, que está en otro componente.

// app/tienda/vista.tsx
'use client'

import { createContext, use, useState, type ReactNode } from 'react'

type Vista = 'tarjetas' | 'lista'
type ValorVista = { vista: Vista; cambiar: (v: Vista) => void }

const ContextoVista = createContext<ValorVista | null>(null)

export function VistaProvider({ children }: { children: ReactNode }) {
  const [vista, cambiar] = useState<Vista>('tarjetas')
  return <ContextoVista value={{ vista, cambiar }}>{children}</ContextoVista>
}

function useVista() {
  const valor = use(ContextoVista)
  if (!valor) throw new Error('useVista debe usarse dentro de <VistaProvider>')
  return valor
}

export function SelectorVista() {
  const { vista, cambiar } = useVista()
  return (
    <div className="flex gap-1 text-sm">
      {(['tarjetas', 'lista'] as const).map((v) => (
        <button
          key={v}
          onClick={() => cambiar(v)}
          aria-pressed={vista === v}
          className={`rounded px-2 py-1 ${vista === v ? 'bg-indigo-700 text-white' : 'bg-slate-200'}`}
        >
          {v === 'tarjetas' ? 'Tarjetas' : 'Lista'}
        </button>
      ))}
    </div>
  )
}

// Recibe como children el HTML que ya renderizó el servidor y solo decide la rejilla
export function ContenedorVista({ children }: { children: ReactNode }) {
  const { vista } = useVista()
  return (
    <ul className={vista === 'tarjetas' ? 'grid grid-cols-3 gap-3' : 'flex flex-col gap-1'} data-vista={vista}>
      {children}
    </ul>
  )
}
// app/tienda/layout.tsx
import { VistaProvider } from './vista'

export default function LayoutTienda({ children }: LayoutProps<'/tienda'>) {
  return <VistaProvider>{children}</VistaProvider>
}

El layout es un Server Component que renderiza un Client Component (VistaProvider) y le pasa children. Esos children (la página de la tienda, con sus consultas a la API) siguen siendo Server Components: el proveedor recibe ya renderizado lo que hay dentro, como viste en el módulo 05. Marcar el proveedor con 'use client' no convierte en cliente todo lo que envuelve.

Lo mismo pasa con ContenedorVista. La lista de productos se genera en el servidor (Resultados en page.tsx), y cada <li> llega como HTML ya hecho; ContenedorVista solo decide las clases de la <ul> que los rodea. Así, cambiar a vista de lista no pide nada al servidor:

vista tras pulsar Lista: lista
2 insignia tras recargar: Carrito: 2 | vista tras recargar: tarjetas

La segunda línea es la otra cara de Context: el estado vive en memoria y se pierde al recargar, mientras que el carrito (Zustand con persist, apartado 7) sobrevive. Si quisieras conservar la vista, tendrías tres opciones: la URL (?vista=lista, si tiene sentido compartirla), localStorage o una cookie (ejercicio 11).

6.3 Dónde colocar los proveedores

  • Lo más abajo posible. VistaProvider está en el layout de /tienda, no en el raíz, porque solo lo usa la tienda. Cuanto más pequeño es el árbol que envuelve, menos componentes vuelven a renderizar cuando cambia el valor.
  • Varios proveedores en un solo fichero. Si la aplicación necesita varios proveedores globales (TanStack Query, un tema…), agrúpalos en un app/providers.tsx con 'use client' y úsalo desde el layout raíz. Es lo que hace el laboratorio (apartado 8.2).
  • Con <Activity>, el estado del proveedor vive lo que vive su layout. Si sales de la tienda y vuelves, VistaProvider estaba oculto, no desmontado, y la vista elegida se conserva (en Next.js 16).

7. Zustand en Next.js

Zustand es un almacén (store) muy pequeño: una función create que devuelve un hook, con selectores para que cada componente se suscriba solo a la parte que usa. El módulo 20 del Curso de React lo explica entero. Aquí nos ocupamos de lo que cambia porque el mismo código se ejecuta en el servidor y en el navegador.

7.1 El peligro: un store de módulo vive en el servidor para todos

create(...) se llama al cargar el módulo. En el navegador eso pasa una vez por pestaña: un store por usuario. Pero los Client Components también se renderizan en el servidor (para generar el HTML inicial), y en el servidor el módulo se carga una sola vez para todas las peticiones de todos los usuarios.

Si algún código del servidor escribe en un store de módulo (por ejemplo, para “inicializarlo” con los datos del usuario de la sesión), el siguiente usuario que pida una página verá esos datos. Es una fuga de datos entre usuarios, y no da ningún error.

⚠️ Importante: Un store de Zustand creado a nivel de módulo es seguro en Next.js solo si nunca se escribe en él durante el render en el servidor. Escribir en él desde manejadores de eventos (onClick), efectos o persist es seguro, porque eso solo ocurre en el navegador. Si necesitas inicializarlo con datos del servidor que dependen del usuario, usa un store por petición (apartado 7.4).

El carrito cumple la regla: empieza vacío en el servidor y solo se modifica con clics del usuario.

7.2 El carrito

// lib/carrito.ts
'use client'

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export type LineaCarrito = { productoId: number; nombre: string; precio: number; cantidad: number }

type EstadoCarrito = {
  lineas: LineaCarrito[]
  anadir: (p: Omit<LineaCarrito, 'cantidad'>) => void
  quitar: (productoId: number) => void
  vaciar: () => void
}

export const useCarrito = create<EstadoCarrito>()(
  persist(
    (set) => ({
      lineas: [],
      anadir: (p) =>
        set((s) => {
          const existe = s.lineas.find((l) => l.productoId === p.productoId)
          return {
            lineas: existe
              ? s.lineas.map((l) => (l.productoId === p.productoId ? { ...l, cantidad: l.cantidad + 1 } : l))
              : [...s.lineas, { ...p, cantidad: 1 }],
          }
        }),
      quitar: (productoId) => set((s) => ({ lineas: s.lineas.filter((l) => l.productoId !== productoId) })),
      vaciar: () => set({ lineas: [] }),
    }),
    {
      name: 'empresa-carrito',
      // No leer localStorage al crear el store: lo haremos tras la hidratación
      skipHydration: true,
    },
  ),
)

// Selectores: cada componente se suscribe solo a lo que usa
export const seleccionarUnidades = (s: EstadoCarrito) => s.lineas.reduce((t, l) => t + l.cantidad, 0)
export const seleccionarTotal = (s: EstadoCarrito) => s.lineas.reduce((t, l) => t + l.cantidad * l.precio, 0)

Tres componentes lo usan, en sitios muy distintos del árbol, y ninguno necesita un proveedor:

// app/insignia-carrito.tsx (en el layout raíz)
'use client'

import Link from 'next/link'
import { useCarrito, seleccionarUnidades } from '@/lib/carrito'

export function InsigniaCarrito() {
  const unidades = useCarrito(seleccionarUnidades)
  return (
    <Link href="/tienda/carrito" className="ml-auto rounded bg-white/15 px-3 py-1 text-sm" data-testid="insignia">
      Carrito: {unidades}
    </Link>
  )
}
// app/tienda/boton-anadir.tsx (dentro de cada tarjeta de producto, renderizada en el servidor)
'use client'

import { useCarrito, type LineaCarrito } from '@/lib/carrito'

export function BotonAnadir({ producto }: { producto: Omit<LineaCarrito, 'cantidad'> }) {
  // Solo la acción: este botón no se vuelve a renderizar cuando cambia el carrito
  const anadir = useCarrito((s) => s.anadir)
  return (
    <button onClick={() => anadir(producto)} className="rounded bg-indigo-700 px-2 py-1 text-sm text-white">
      Añadir
    </button>
  )
}
// app/tienda/carrito/page.tsx
'use client'

import { useCarrito, seleccionarTotal } from '@/lib/carrito'

const euros = (n: number) => n.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })

export default function PaginaCarrito() {
  const lineas = useCarrito((s) => s.lineas)
  const total = useCarrito(seleccionarTotal)
  const { quitar, vaciar } = useCarrito.getState()

  if (lineas.length === 0) return <p>El carrito está vacío.</p>

  return (
    <>
      <h1 className="mb-4 text-2xl font-bold">Carrito</h1>
      <table className="w-full bg-white text-left">
        <tbody>
          {lineas.map((l) => (
            <tr key={l.productoId} className="border-b">
              <td className="p-2">{l.nombre}</td>
              <td className="p-2">{l.cantidad} ud.</td>
              <td className="p-2">{euros(l.cantidad * l.precio)}</td>
              <td className="p-2">
                <button onClick={() => quitar(l.productoId)}>Quitar</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="mt-3 font-semibold" data-testid="total-carrito">Total: {euros(total)}</p>
      <button onClick={vaciar} className="mt-2 rounded bg-slate-200 px-3 py-1">Vaciar</button>
    </>
  )
}

Fíjate en BotonAnadir: es una isla de cliente dentro de una tarjeta que renderiza el servidor. La tarjeta (nombre, categoría, precio) no envía JavaScript; solo el botón lo hace. Recibe del servidor los datos del producto como props serializables (números y textos).

La página del carrito es entera un Client Component, porque todo su contenido sale del navegador (localStorage). En el build sale como ○ (Static) con el texto “El carrito está vacío.”: en el servidor el carrito siempre está vacío.

La página del carrito con dos portátiles, Gaming 16 por 1499 euros y UltraBook 14 por 899, un total de 2398 euros y el botón Vaciar

Figura 5. La página del carrito después de añadir dos productos desde la tienda.

7.3 persist y la hidratación

persist guarda el estado en localStorage y lo recupera al cargar. Aquí aparece la pregunta clásica de Next.js: el servidor generó el HTML con el carrito vacío (“Carrito: 0”), pero el navegador tiene dos productos guardados. ¿Qué ve el usuario y qué dice React al hidratar?

Para averiguarlo se probó primero la versión “ingenua”, sin skipHydration, que lee localStorage en cuanto se crea el store:

// lib/carrito-ingenuo.ts
'use client'

// Versión con el error típico: persist lee localStorage en cuanto se crea el store
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type Estado = { unidades: number; sumar: () => void }

export const useCarritoIngenuo = create<Estado>()(
  persist((set) => ({ unidades: 0, sumar: () => set((s) => ({ unidades: s.unidades + 1 })) }), {
    name: 'carrito-ingenuo',
  }),
)

Se sumaron dos unidades, se recargó la página y se recogió la consola del navegador, tanto en next start como en next dev:

3 boton tras recargar: Unidades: 2
3 consola: []

Ningún error de hidratación. Es un resultado que contradice muchos artículos antiguos, y tiene explicación. El hook de Zustand 5 usa useSyncExternalStore de React con tres funciones; la tercera es la que React usa durante la hidratación:

// node_modules/zustand/esm/react.mjs (Zustand 5.0.16)
const slice = React.useSyncExternalStore(
  api.subscribe,
  React.useCallback(() => selector(api.getState()), [api, selector]),
  () => selector(api.getInitialState())
);

Durante la hidratación, React pide a Zustand el estado inicial (getInitialState(), el que había antes de que persist leyera localStorage), que coincide con lo que renderizó el servidor. Justo después, React vuelve a renderizar con el estado real. El error de hidratación con persist era real en Zustand 4, que no tenía esa tercera función; en Zustand 5 ya no ocurre.

Entonces, ¿para qué sirve skipHydration: true? Para controlar cuándo se lee el almacenamiento:

  • Con la versión ingenua, persist lee localStorage al cargar el módulo, antes de que React haga nada. Con skipHydration, no lee nada hasta que tú llamas a rehydrate(), y eso lo haces en un efecto, cuando la página ya está hidratada:

    // app/providers.tsx (fragmento)
    useEffect(() => {
      // Ya hidratado: ahora sí, leer el carrito guardado en localStorage
      void useCarrito.persist.rehydrate()
    }, [])
    
  • Te permite saber si ya se ha leído, con useCarrito.persist.hasHydrated() y onFinishHydration. En las dos versiones, el usuario ve un instante “Carrito: 0” antes de “Carrito: 2” (el HTML del servidor lo confirma: contiene Carrito: 0). Si ese parpadeo molesta, puedes mostrar otra cosa mientras tanto (ejercicio 4).
  • Funciona igual con almacenamientos asíncronos (IndexedDB, por ejemplo), donde la lectura nunca es inmediata.

📘 Concepto: Todo estado guardado en el navegador (localStorage, sessionStorage, IndexedDB) es invisible para el servidor. El HTML inicial siempre mostrará el valor por defecto. Si el valor debe estar correcto desde el primer píxel (el tema oscuro, el idioma), guárdalo en una cookie, que el servidor sí recibe con cada petición, como viste en el módulo 06 con el modo oscuro sin parpadeo.

7.4 Un store por petición

Cuando el estado inicial depende del usuario y lo conoce el servidor (sus preferencias, guardadas en una cookie o en la base de datos), un store de módulo no sirve: como vimos, se compartiría entre peticiones. El patrón correcto es crear el store dentro de un componente, una vez por cada árbol que lo monta, y repartirlo con Context:

// app/ejercicios/e9/preferencias.tsx
'use client'

import { createContext, use, useState, type ReactNode } from 'react'
import { createStore, useStore, type StoreApi } from 'zustand'

type Preferencias = { moneda: string; cambiarMoneda: (m: string) => void }

const crearStorePreferencias = (inicial: { moneda: string }) =>
  createStore<Preferencias>()((set) => ({
    ...inicial,
    cambiarMoneda: (moneda) => set({ moneda }),
  }))

const ContextoPreferencias = createContext<StoreApi<Preferencias> | null>(null)

// Un store por cada árbol que monta el provider: en el servidor, uno por petición
export function PreferenciasProvider({ moneda, children }: { moneda: string; children: ReactNode }) {
  const [store] = useState(() => crearStorePreferencias({ moneda }))
  return <ContextoPreferencias value={store}>{children}</ContextoPreferencias>
}

export function usePreferencias<T>(selector: (s: Preferencias) => T): T {
  const store = use(ContextoPreferencias)
  if (!store) throw new Error('usePreferencias necesita <PreferenciasProvider>')
  return useStore(store, selector)
}

Las claves:

  • createStore (de zustand, sin React) crea un store sin hook. useStore(store, selector) lo lee desde un componente.
  • useState(() => crear...) crea el store una sola vez por montaje del proveedor. En el servidor, cada petición renderiza un árbol nuevo, así que cada usuario tiene el suyo.
  • El servidor pasa el valor inicial como prop. Lo verás completo en el ejercicio 9, donde la moneda sale de una cookie.

Es el mismo patrón que el módulo 20 del Curso de React llama “un store por rama del árbol”, y es la recomendación oficial de Zustand para Next.js cuando el estado inicial viene del servidor.

7.5 ¿Zustand o Context?

Criterio Context + useState Zustand
Dependencias Ninguna Un paquete pequeño
Necesita proveedor Sí No (store de módulo) o sí (store por petición)
Rendimiento Todos los consumidores vuelven a renderizar al cambiar el valor Cada componente solo se renderiza si cambia lo que selecciona
Uso fuera de React No Sí (useCarrito.getState() en cualquier función)
Persistencia A mano persist
Encaja con Valores que cambian poco: tema, vista, usuario Estado que cambia a menudo o que se usa en muchos sitios: carrito, asistentes de varios pasos

En EMPRESA, la vista de la tienda (cambia poco, la usan dos componentes) va en Context; el carrito (cambia con cada clic, lo usan la cabecera, cada tarjeta y su propia página, debe persistir) va en Zustand.


8. TanStack Query junto a Server Components

8.1 ¿Hace falta?

En una SPA, TanStack Query es casi imprescindible. En Next.js con Server Components, la mayoría de las páginas no lo necesitan: el servidor pide los datos, la caché de Next los guarda y las Server Actions los invalidan. Añadirlo “por si acaso” duplica cachés y complica el código.

Sí tiene sentido cuando los datos deben cambiar en el navegador sin una navegación:

  • Datos en vivo: un panel de pedidos que se refresca cada 15 segundos (refetchInterval) o al volver a la pestaña.
  • Scroll infinito y paginación en el cliente (useInfiniteQuery), donde las páginas se van sumando.
  • Interacciones con muchas peticiones pequeñas: autocompletar, búsquedas mientras escribes en un componente aislado.
  • Actualizaciones optimistas complejas, con reversión si falla (apartado 11 del módulo 19 del Curso de React).
  • Filtros que no pasan por el servidor (History API), como los estados del panel de pedidos.

La documentación de Next.js 16.4 lo resume en su guía de datos en el cliente: el servidor da los datos iniciales, y TanStack Query se encarga de lo que pasa después en el navegador.

8.2 El proveedor: un QueryClient por petición en el servidor

// app/providers.tsx
'use client'

import { useEffect, type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useCarrito } from '@/lib/carrito'

let clienteNavegador: QueryClient | undefined

function obtenerQueryClient() {
  // En el servidor, uno nuevo por cada render: nunca compartir caché entre usuarios
  if (typeof window === 'undefined') return new QueryClient()
  // En el navegador, siempre el mismo: si React vuelve a renderizar, la caché sigue ahí
  clienteNavegador ??= new QueryClient()
  return clienteNavegador
}

export function Providers({ children }: { children: ReactNode }) {
  useEffect(() => {
    // Ya hidratado: ahora sí, leer el carrito guardado en localStorage
    void useCarrito.persist.rehydrate()
  }, [])

  return <QueryClientProvider client={obtenerQueryClient()}>{children}</QueryClientProvider>
}
// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { Providers } from './providers'
import { InsigniaCarrito } from './insignia-carrito'
import './globals.css'

export const metadata: Metadata = { title: 'EMPRESA' }

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="min-h-screen bg-slate-50 text-slate-800">
        <Providers>
          <header className="flex items-center gap-6 bg-indigo-900 px-6 py-3 text-white">
            <strong>EMPRESA</strong>
            <nav className="flex gap-4 text-sm">
              <Link href="/tienda">Tienda</Link>
              <Link href="/gestion/pedidos">Pedidos</Link>
              <Link href="/gestion/empleados">Empleados</Link>
              <Link href="/gestion/resumen">Resumen</Link>
            </nav>
            <InsigniaCarrito />
          </header>
          <main className="mx-auto max-w-5xl p-6">{children}</main>
        </Providers>
      </body>
    </html>
  )
}

obtenerQueryClient es el mismo razonamiento del apartado 7.1 aplicado a TanStack Query: el QueryClient es una caché. En el servidor debe ser nuevo en cada render; en el navegador, uno solo para toda la sesión.

¿Por qué no un simple useState(() => new QueryClient()), como en una SPA? Funciona casi siempre, pero si React descarta el primer render (porque un componente suspende antes de que el proveedor termine de montarse), el useState se pierde y se crea otro cliente vacío. La variable de módulo en el navegador lo evita. Es el patrón que recomiendan tanto la guía de Next.js como la de TanStack Query.

8.3 El contrato de caché: claves compartidas

Servidor y cliente deben usar exactamente la misma clave (queryKey) para que los datos precargados en el servidor sirvan en el cliente. La forma más segura es definir las claves y las opciones una sola vez, en un módulo que puedan importar los dos lados:

// lib/consultas.ts
// El "contrato" de caché que comparten el servidor y el cliente: claves, etiquetas y funciones.
// Sin 'server-only' ni 'use client': lo importan los dos lados.
import { queryOptions } from '@tanstack/react-query'
import type { Empleado, Estadisticas, Pagina, Pedido } from './tipos'

const API_PUBLICA = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api'

async function pedir<T>(ruta: string): Promise<T> {
  const respuesta = await fetch(`${API_PUBLICA}${ruta}`)
  if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`)
  return respuesta.json() as Promise<T>
}

export const pedidosCache = {
  clave: (estado: string | null) => ['pedidos', estado ?? 'TODOS'] as const,
  etiqueta: 'pedidos',
  opciones: (estado: string | null) =>
    queryOptions({
      queryKey: pedidosCache.clave(estado),
      queryFn: async () => {
        const datos = await pedir<Pagina<Pedido>>(`/pedidos?tamano=100${estado ? `&estado=${estado}` : ''}`)
        return datos.contenido
      },
      staleTime: 10_000,
      refetchInterval: 15_000,
    }),
}

export const estadisticasCache = {
  clave: ['estadisticas'] as const,
  etiqueta: 'estadisticas',
  opciones: () =>
    queryOptions({
      queryKey: estadisticasCache.clave,
      queryFn: () => pedir<Estadisticas>('/estadisticas'),
      staleTime: 60_000,
    }),
}

export const empleadosOpciones = () =>
  queryOptions({
    queryKey: ['empleados'],
    queryFn: async () => (await pedir<Pagina<Empleado>>('/empleados?tamano=100')).contenido,
  })

Cada contrato reúne las dos identidades de un dato:

  • clave: la queryKey de TanStack Query (caché del navegador).
  • etiqueta: la etiqueta de cacheTag de Next (caché del servidor). La Server Action que modifica pedidos invalidará esa etiqueta (apartado 8.7).

queryOptions no hace nada en tiempo de ejecución: devuelve el mismo objeto, pero con los tipos unidos, de modo que useQuery(pedidosCache.opciones(estado)) sabe que data es Pedido[].

⚠️ Importante: El contrato no debe importar server-only ni nada que solo exista en el navegador. Si lib/consultas.ts importara lib/api.ts (que tiene 'server-only'), el build fallaría en cuanto un Client Component lo usara. La función de servidor se pasa aparte, al precargar.

8.4 Precargar en el servidor con HydrationBoundary

El panel de pedidos debe mostrar los datos en el primer HTML, sin esperar a que el navegador los pida, y después refrescarlos cada 15 segundos. El patrón tiene cuatro pasos:

Dos columnas: en el servidor, new QueryClient, prefetchQuery, dehydrate y HydrationBoundary; una flecha lleva los datos al navegador, donde el QueryClient del navegador los hidrata, useQuery los pinta sin petición y después TanStack Query gestiona staleTime, refetchInterval e invalidaciones

Figura 6. El servidor rellena una caché temporal, la convierte en datos (dehydrate) y la envía. El navegador la copia en su propia caché, que manda a partir de ahí.

// app/gestion/pedidos/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { obtenerPedidos } from '@/lib/api'
import { pedidosCache } from '@/lib/consultas'
import { TablaPedidos } from './tabla-pedidos'

const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']

export default function PaginaPedidos({ searchParams }: PageProps<'/gestion/pedidos'>) {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold">Pedidos</h1>
      <Suspense fallback={<p>Cargando pedidos…</p>}>
        <DatosPedidos busqueda={searchParams} />
      </Suspense>
    </>
  )
}

async function DatosPedidos({ busqueda }: { busqueda: PageProps<'/gestion/pedidos'>['searchParams'] }) {
  const { estado } = await busqueda
  const filtro = typeof estado === 'string' && ESTADOS.includes(estado) ? estado : null
  // TanStack Query lee la hora (Date.now) al guardar la consulta: este trozo es de cada petición
  await connection()

  // Un QueryClient nuevo por petición: lo que se guarde aquí es solo de este usuario
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({
    ...pedidosCache.opciones(filtro),
    // En el servidor no hace falta ir por la red pública: usamos la función cacheada de servidor
    queryFn: () => obtenerPedidos(filtro),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TablaPedidos />
    </HydrationBoundary>
  )
}

Paso a paso:

  1. new QueryClient() dentro del componente, no fuera: uno por petición.
  2. prefetchQuery con las opciones del contrato, pero cambiando queryFn por la función de servidor obtenerPedidos. Así el servidor no llama a la API por la URL pública, y además aprovecha la caché de Next ('use cache' con la etiqueta pedidos). La clave es la misma que usará el cliente.
  3. dehydrate(queryClient) convierte el contenido de la caché en un objeto serializable: claves, datos y la hora en que se obtuvieron (dataUpdatedAt).
  4. <HydrationBoundary state={...}> es un Client Component de TanStack Query. Recibe ese objeto como prop (viaja en la carga RSC) y, al renderizarse en el navegador, lo copia en el QueryClient del navegador. Solo copia datos más recientes que los que ya tenga.

La tabla es un Client Component normal, que usa useQuery con las mismas opciones y no sabe nada de la precarga:

// app/gestion/pedidos/tabla-pedidos.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { pedidosCache } from '@/lib/consultas'
import { marcarEnviado } from './acciones'

const ESTADOS = [null, 'PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const

export function TablaPedidos() {
  const estado = useSearchParams().get('estado')
  const queryClient = useQueryClient()
  const { data = [], isFetching, isPlaceholderData } = useQuery({
    ...pedidosCache.opciones(estado),
    placeholderData: keepPreviousData,
  })

  const enviar = useMutation({
    mutationFn: marcarEnviado,
    // La caché del navegador la gestiona TanStack Query: la marcamos como caducada
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pedidos'] }),
  })

  function filtrar(nuevo: string | null) {
    const p = new URLSearchParams(window.location.search)
    if (nuevo) p.set('estado', nuevo)
    else p.delete('estado')
    // Cambia la URL sin pedir nada al servidor; useSearchParams se entera igualmente
    window.history.replaceState(null, '', `?${p}`)
  }

  return (
    <>
      <div className="mb-3 flex items-center gap-2 text-sm">
        {ESTADOS.map((e) => (
          <button
            key={e ?? 'TODOS'}
            onClick={() => filtrar(e)}
            aria-pressed={estado === e}
            className={`rounded px-2 py-1 ${estado === e ? 'bg-indigo-700 text-white' : 'bg-slate-200'}`}
          >
            {e ?? 'Todos'}
          </button>
        ))}
        {isFetching && <span className="text-indigo-700" data-testid="cargando">Actualizando…</span>}
      </div>
      <table className={`w-full bg-white text-left text-sm ${isPlaceholderData ? 'opacity-50' : ''}`}>
        <thead>
          <tr className="border-b bg-slate-100">
            <th className="p-2">Pedido</th>
            <th className="p-2">Cliente</th>
            <th className="p-2">Fecha</th>
            <th className="p-2">Estado</th>
            <th className="p-2 text-right">Total</th>
            <th className="p-2" />
          </tr>
        </thead>
        <tbody data-testid="filas">
          {data.map((p) => (
            <tr key={p.pedidoId} className="border-b">
              <td className="p-2">{p.pedidoId}</td>
              <td className="p-2">{p.cliente}</td>
              <td className="p-2">{p.fechaPedido}</td>
              <td className="p-2">{p.estado}</td>
              <td className="p-2 text-right">
                {p.total.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}
              </td>
              <td className="p-2">
                {p.estado === 'PENDIENTE' && (
                  <button
                    onClick={() => enviar.mutate(p.pedidoId)}
                    disabled={enviar.isPending}
                    className="rounded bg-indigo-700 px-2 py-0.5 text-white disabled:opacity-50"
                  >
                    Marcar enviado
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="mt-2 text-sm text-slate-500" data-testid="num-pedidos">{data.length} pedidos</p>
    </>
  )
}

Al abrir /gestion/pedidos?estado=PENDIENTE se contaron las peticiones del navegador:

4 carga pedidos PENDIENTE: 2 pedidos | peticiones del navegador: [
  'RSC /tienda',
  'RSC /gestion/empleados',
  'RSC /gestion/resumen',
  'RSC /tienda/carrito',
  ...
]

Ninguna petición a la API desde el navegador: los dos pedidos pendientes llegaron precargados. Las peticiones RSC que aparecen son las precargas de los enlaces del menú (las que estudiaste en el módulo 03), no tienen nada que ver con los pedidos.

El panel de pedidos con los botones de filtro Todos, PENDIENTE, ENVIADO, ENTREGADO y CANCELADO, y una tabla con número de pedido, cliente, fecha, estado y total

Figura 7. El panel de pedidos. Los datos iniciales llegan del servidor; los filtros y el refresco los gestiona TanStack Query en el navegador.

8.5 Filtrar en el cliente y refrescar solo

Los botones de estado usan history.replaceState (apartado 4.7): cambian la URL sin ir al servidor. useSearchParams devuelve el estado nuevo, la queryKey cambia y TanStack Query pide solo los datos, directamente a la API:

4 filtro ENVIADO: 3 pedidos | peticiones: [ 'API /api/pedidos?tamano=100&estado=ENVIADO' ]
4 vuelta a PENDIENTE: 2 pedidos | peticiones: []

Al volver a PENDIENTE no hay ninguna petición: esos datos ya estaban en la caché del navegador y seguían frescos (staleTime: 10_000). Con placeholderData: keepPreviousData, mientras llegan los datos de un estado nuevo, la tabla muestra los anteriores con opacidad, en lugar de quedarse vacía.

Después, se dejó la página quieta 16 segundos:

4 peticiones en 16 s quieto (refetchInterval): [ 'API /api/pedidos?tamano=100&estado=PENDIENTE' ] 16009 ms

Una petición: la de refetchInterval: 15_000. Esto es lo que un Server Component no puede hacer sin ayuda, y la razón de usar TanStack Query en este panel.

💡 Consejo: Al cargar la página con ?estado=ENVIADO (por ejemplo, desde un enlace compartido), el servidor precarga ese estado, porque lee searchParams. Los cambios posteriores los hace el cliente. Así se combinan las dos ventajas: un primer HTML correcto para cualquier URL y filtros instantáneos después.

8.6 Cache Components y la hora: el error de Date.now() 🔄 16.0

🔄 Cambia en Next.js 16.0. Con Cache Components, Next no permite leer la hora actual mientras prerenderiza, y TanStack Query la lee al guardar una consulta. En Next.js 15 no existe esa restricción: el dehydrate estándar compila en una página estática (se comprobó: ○ /hidratado con revalidate de 5 minutos). Si usas Next.js 15: usa el patrón del apartado 8.4 sin connection() y sin el ayudante del apartado 8.7.

En el apartado 8.4 hay una línea que no se ha explicado: await connection(). Sin ella, la página de pedidos compila y funciona, pero el registro del servidor se llenaba de este error (cuatro veces durante la prueba en next start, y también en la consola del navegador con next dev):

Error: Route "/gestion/pedidos": Next.js encountered the unstable value `Date.now()` while prerendering.

This value can change between renders, so it must be either prerendered or computed later.

Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
  - [cache] Prerender and cache the value with `"use cache"`
  - [client] Render the value on the client with `"use client"`
  - [measure] If the value is for telemetry, use a timing API such as `performance.now()`

Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time
    at async DatosPedidos (app\gestion\pedidos\page.tsx:26:3)

La línea señalada es la de prefetchQuery. TanStack Query llama a Date.now() para anotar cuándo se obtuvieron los datos (dataUpdatedAt), y con Cache Components Next.js vigila la hora: un valor que cambia en cada render no puede formar parte de algo prerenderizado. Que el componente haya esperado antes a searchParams no basta: Next puede prerenderizar ese hueco cuando ya conoce la cadena de consulta (por ejemplo, al precargar un enlace), y en esa prerenderización la hora sigue prohibida. await connection() le dice a Next sin ambigüedad que todo lo que sigue pertenece a la petición del usuario. Con esa línea, el registro quedó limpio (0 apariciones de Date.now() en la segunda prueba).

Si la página no tiene nada dinámico, el problema es peor: el build falla. Se probó con una página estática que precargaba las estadísticas con el patrón normal:

// app/errores/dehydrate/page.tsx
async function Datos() {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({ ...estadisticasCache.opciones(), queryFn: obtenerEstadisticas })
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TarjetasResumen />
    </HydrationBoundary>
  )
}
Error: Route "/errores/dehydrate": Next.js encountered the unstable value `Date.now()` while prerendering.
...
    at async j (app\errores\dehydrate\page.tsx:17:3)
  15 | async function Datos() {
  16 |   const queryClient = new QueryClient()
> 17 |   await queryClient.prefetchQuery({ ...estadisticasCache.opciones(), queryFn: obtenerEstadisticas })
     |   ^
Export encountered an error on /errores/dehydrate/page: /errores/dehydrate, exiting the build.

Estaba dentro de un <Suspense>, y aun así falla: <Suspense> permite esperar datos, pero no permite que un valor cambiante (la hora) acabe en el HTML estático. Hay dos salidas, según lo que quieras:

Quieres Solución Resultado
Datos de cada petición (pedidos de hoy, datos del usuario) await connection() antes de precargar ◐, el hueco se renderiza en cada petición
Datos que pueden ser estáticos (estadísticas, catálogo) Construir el estado deshidratado a mano con una hora cacheada (apartado 8.7) ○, la página entera se prerenderiza

8.7 Datos prerenderizados para TanStack Query

La guía de TanStack Query en la documentación de Next.js 16.4 propone un ayudante para el segundo caso: en vez de llamar a dehydrate, construir a mano el estado deshidratado usando como hora un valor guardado en caché con las mismas etiquetas que los datos. Así la hora no cambia en cada render (es la de cuando se guardó la caché), y avanza junta con los datos cuando una mutación invalida la etiqueta.

// lib/hidratar.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import {
  defaultShouldDehydrateQuery,
  QueryClient,
  type DehydratedState,
  type QueryKey,
} from '@tanstack/react-query'

type ConsultaHidratada = { queryKey: QueryKey; data: unknown }

// La hora de los datos se guarda en caché con las mismas etiquetas que los datos:
// si se invalidan, avanzan juntos.
async function horaDeLosDatos(etiquetas: string[]): Promise<number> {
  'use cache'
  cacheTag(...etiquetas)
  cacheLife('max')
  return Date.now()
}

// Sustituto de dehydrate() que se puede prerenderizar con Cache Components
export async function deshidratar(
  consultas: ConsultaHidratada[],
  etiquetas: string[],
): Promise<DehydratedState> {
  const hora = await horaDeLosDatos(etiquetas)
  const cliente = new QueryClient()
  for (const c of consultas) cliente.setQueryData(c.queryKey, c.data, { updatedAt: hora })

  return {
    mutations: [],
    queries: cliente
      .getQueryCache()
      .getAll()
      .filter((q) => defaultShouldDehydrateQuery(q))
      .map((q) => ({
        dehydratedAt: hora,
        queryHash: q.queryHash,
        queryKey: q.queryKey,
        state: q.state,
      })),
  }
}

Dentro de una función con 'use cache', Date.now() sí está permitido: su resultado se guarda y se reutiliza, que es justo lo que Next exige. setQueryData con updatedAt evita que TanStack Query lea la hora por su cuenta.

La página de resumen lo usa:

// app/gestion/resumen/page.tsx
import { Suspense } from 'react'
import { HydrationBoundary } from '@tanstack/react-query'
import { obtenerEstadisticas } from '@/lib/api'
import { estadisticasCache } from '@/lib/consultas'
import { deshidratar } from '@/lib/hidratar'
import { TarjetasResumen } from './tarjetas-resumen'

export default function PaginaResumen() {
  return (
    <>
      <h1 className="mb-4 text-2xl font-bold">Resumen</h1>
      <Suspense fallback={<p>Cargando…</p>}>
        <DatosResumen />
      </Suspense>
    </>
  )
}

async function DatosResumen() {
  const estadisticas = await obtenerEstadisticas()
  const estado = await deshidratar(
    [{ queryKey: estadisticasCache.clave, data: estadisticas }],
    [estadisticasCache.etiqueta],
  )
  return (
    <HydrationBoundary state={estado}>
      <TarjetasResumen />
    </HydrationBoundary>
  )
}
// app/gestion/resumen/tarjetas-resumen.tsx
'use client'

import { useSuspenseQuery } from '@tanstack/react-query'
import { estadisticasCache } from '@/lib/consultas'

export function TarjetasResumen() {
  const { data, isFetching } = useSuspenseQuery(estadisticasCache.opciones())
  const tarjetas = [
    ['Empleados', data.empleados],
    ['Clientes', data.clientes],
    ['Productos', data.productos],
    ['Pedidos', data.pedidos],
  ] as const
  return (
    <>
      <div className="grid grid-cols-4 gap-3" data-testid="tarjetas">
        {tarjetas.map(([titulo, valor]) => (
          <div key={titulo} className="rounded border bg-white p-4">
            <p className="text-sm text-slate-500">{titulo}</p>
            <p className="text-3xl font-bold text-indigo-900">{valor}</p>
          </div>
        ))}
      </div>
      <p className="mt-3 text-sm">
        Facturación: {data.facturacion.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}
        {isFetching && ' · actualizando…'}
      </p>
    </>
  )
}

useSuspenseQuery garantiza que data nunca es undefined (si no hubiera datos, suspendería hasta tenerlos); como llegan hidratados, no suspende. El build la marca como estática, con la vida de cacheLife('max'):

├ ○ /gestion/resumen              30d      1y

El resumen de EMPRESA con cuatro tarjetas: 24 empleados, 14 clientes, 19 productos y 24 pedidos, y la facturación de 61.617,46 euros

Figura 8. La página de resumen, prerenderizada en el build con los datos hidratados en TanStack Query.

Pero hay una sorpresa al abrirla en el navegador:

6 resumen: Empleados24Clientes14Productos19Pedidos24 | peticiones navegador: [ ..., 'API /api/estadisticas', ... ]

El navegador pidió las estadísticas otra vez nada más cargar. La razón: la hora de los datos es la del build. Cuando el usuario abre la página varios minutos después, TanStack Query compara esa hora con su staleTime (60 segundos), ve que los datos son “viejos” y los vuelve a pedir en segundo plano. La página se ve al instante con los datos prerenderizados, pero hay una petición más.

💡 Consejo: Con datos prerenderizados, staleTime se cuenta desde que se guardó la caché, no desde que el usuario abrió la página. Si los datos solo cambian por mutaciones (que invalidan la etiqueta y avanzan la hora), usa staleTime: Infinity en esa consulta: el navegador no volverá a pedirlos hasta que tú los invalides. Si deben refrescarse con el tiempo, acepta la petición de más o acorta cacheLife.

8.8 Mutaciones: dos cachés que coordinar 🆕 16.0

🆕 Desde Next.js 16.0. updateTag no existe en Next.js 15. Si usas Next.js 15: llama a revalidateTag('pedidos') (con un solo argumento) en la Server Action; el resto del patrón (useMutation e invalidateQueries) es igual.

Cuando el usuario pulsa “Marcar enviado”, hay dos cachés que guardan pedidos:

  1. La caché de Next en el servidor: obtenerPedidos con cacheTag('pedidos').
  2. La caché de TanStack Query en el navegador: las claves ['pedidos', ...].

La Server Action modifica el pedido y caduca la primera. El cliente caduca la segunda:

// app/gestion/pedidos/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { pedidosCache } from '@/lib/consultas'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

// En el módulo 17 el token sale de la sesión del usuario. Aquí, para centrarnos en el estado,
// la acción inicia sesión con la cuenta de prueba de la API.
async function token(): Promise<string> {
  const r = await fetch(`${API}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
  })
  return (await r.json()).token
}

export async function marcarEnviado(pedidoId: number): Promise<void> {
  if (!Number.isInteger(pedidoId)) throw new Error('Identificador no válido')
  const r = await fetch(`${API}/pedidos/${pedidoId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await token()}` },
    body: JSON.stringify({ estado: 'ENVIADO' }),
  })
  if (!r.ok) throw new Error(`No se pudo cambiar el pedido ${pedidoId}`)
  // La caché de servidor ('use cache' con cacheTag('pedidos')) queda caducada al momento
  updateTag(pedidosCache.etiqueta)
}

⚠️ Importante: En una aplicación real, la acción debe comprobar la sesión y el rol del usuario antes de tocar nada, como aprendiste en el módulo 17. Una Server Action es un punto de entrada público.

Una Server Action se puede usar como mutationFn de useMutation directamente: es una función asíncrona que recibe argumentos serializables. En la tabla:

const enviar = useMutation({
  mutationFn: marcarEnviado,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pedidos'] }),
})

invalidateQueries({ queryKey: ['pedidos'] }) caduca todas las consultas cuya clave empieza por 'pedidos' (las de todos los estados); la que está en pantalla se vuelve a pedir enseguida, y las demás cuando se usen.

El registro de la API al pulsar el botón en la vista de pendientes:

[API 13:21:06.023] POST /api/auth/login
[API 13:21:06.335] PATCH /api/pedidos/5022
[API 13:21:06.665] GET /api/pedidos?tamano=100&estado=PENDIENTE
[API 13:21:06.676] GET /api/pedidos?tamano=100&estado=PENDIENTE
4 tras marcar enviado: 1 pedidos | peticiones: [ 'API /api/pedidos?tamano=100&estado=PENDIENTE', 'RSC /tienda', ... ]

Dos lecturas de pendientes, separadas por 11 ms. Una es la de invalidateQueries (sale del navegador, aparece en la lista de peticiones del navegador). La otra la hace el servidor: una Server Action que llama a updateTag devuelve, en la misma respuesta, la página actual renderizada de nuevo con datos frescos (módulo 10), y DatosPedidos vuelve a precargar. Además, Next vacía su caché de navegación y vuelve a precargar los enlaces visibles: son las peticiones RSC que siguen.

¿Sobra una de las dos? Depende:

  • Si solo usas updateTag, la respuesta de la acción trae un HydrationBoundary con datos más recientes, que TanStack Query copia en su caché. Funciona para la consulta que precarga la página, pero no caduca las demás claves (['pedidos', 'ENVIADO'], el contador del ejercicio 3…).
  • Si solo usas invalidateQueries, el navegador queda al día, pero la caché del servidor seguiría sirviendo la lista vieja a quien recargue la página durante la vida de cacheLife.

Lo robusto es mantener las dos, cada una en su caché, que es lo que recomienda la guía de Next.js. Tras recargar, el servidor ya devuelve la lista correcta:

4 tras recargar: 1 pedidos

Para dar respuesta inmediata, sin esperar a la API, se puede añadir una actualización optimista con onMutate (ejercicio 8).

8.9 Sin precarga: TanStack Query solo en el cliente

La tabla de empleados (apartado 4.7) usa useQuery sin precargar nada en el servidor. Es la forma más sencilla y sirve cuando el primer render puede mostrar “Cargando…”: un panel interno, una pestaña secundaria, datos que dependen de algo que solo sabe el navegador.

La página compila como ○ y el HTML lleva el estado de carga. Como el componente usa useSearchParams, está dentro de <Suspense>. Un useQuery en un Client Component sin useSearchParams y sin <Suspense> también compiló sin errores como ○ (ejercicio 3): el HTML prerenderizado no lleva datos, porque useQuery no suspende y devuelve el estado pendiente, y los datos llegan cuando el componente se monta en el navegador.

Patrón Primer HTML Peticiones del navegador al cargar Cuándo usarlo
Server Component sin TanStack Query Con datos Ninguna Por defecto
Precarga + HydrationBoundary + connection() Con datos Ninguna Datos por petición que luego se refrescan en el cliente
Ayudante deshidratar con hora cacheada Con datos (estático) Una si los datos superan staleTime Datos que pueden ser estáticos y se refrescan en el cliente
Solo useQuery en el cliente Estado de carga Una por consulta Datos secundarios o que dependen del navegador

9. Caso práctico: EMPRESA con cada estado en su sitio

Con todo lo anterior, la aplicación EMPRESA reparte su estado así:

Pantalla Dato Dónde vive Por qué
Tienda Productos Servidor ('use cache' por combinación de filtros) Iguales para todos, cambian poco
Tienda Búsqueda, categoría, orden, página URL (searchParams en el servidor, useSearchParams en los filtros) Se comparten y funcionan con Atrás
Tienda Vista tarjetas/lista Context en el layout de la tienda Preferencia de la sesión, la usan dos componentes
Toda la aplicación Carrito Zustand con persist Lo usan la cabecera, cada tarjeta y su página; debe sobrevivir a recargas
Pedidos Lista de pedidos TanStack Query precargado desde el servidor Se refresca solo cada 15 s
Pedidos Estado filtrado URL con history.replaceState Se puede compartir, pero no necesita al servidor
Pedidos Cambio de estado Server Action + updateTag + invalidateQueries Dos cachés que mantener al día
Empleados Lista TanStack Query solo en el cliente Pantalla interna, admite estado de carga
Empleados Columna de orden URL con history.pushState Atrás deshace el orden
Empleados Texto del filtro useState Efímero; <Activity> lo conserva al navegar
Resumen Estadísticas Prerenderizadas e hidratadas en TanStack Query Estáticas, invalidables por etiqueta

9.1 Recorrido

  1. El usuario entra en /tienda. El HTML trae el armazón (título, selector de vista, esqueletos) y, por streaming, los filtros y los seis primeros productos. La insignia dice “Carrito: 0” un instante y enseguida “Carrito: 2”, cuando Providers rehidrata el carrito.
  2. Elige “Portátiles” y “Precio descendente”. Cada cambio es un router.replace con una petición RSC; la URL queda en /tienda?categoria=2&orden=-precio (figura 3). Si copia el enlace y lo abre en otra pestaña, ve lo mismo.
  3. Pulsa “Lista”. El Context cambia la rejilla sin pedir nada al servidor.
  4. Añade dos portátiles. El store de Zustand se actualiza, la insignia cambia y persist lo guarda en localStorage.
  5. Va a Pedidos. Los pendientes llegan precargados. Filtra por ENVIADO (petición directa a la API), vuelve a PENDIENTE (de la caché) y marca uno como enviado (Server Action e invalidación de las dos cachés).
  6. Va a Empleados, filtra por “ez” y ordena por salario. Sale a la tienda y vuelve: el filtro sigue (<Activity>), el orden depende de cómo vuelva (enlace o Atrás).
  7. Recarga la página. El carrito sigue (persist), la vista vuelve a tarjetas (Context no persiste), el filtro de empleados se pierde (useState), el orden de la URL se conserva.

10. Depurar el estado

Cuando un estado “no se actualiza” o “se pierde”, casi siempre es porque vive en un sitio distinto del que crees. Estas herramientas te dicen dónde está y qué lo cambia:

  • Pestaña Red, filtrando por _rsc. Cada navegación de Next (enlace, router.push, router.replace, <Form>) es una petición con ?_rsc= en la URL y la cabecera RSC: 1. Si cambias un filtro y no aparece ninguna, estás usando la History API y el servidor no se ha enterado. Si aparece una por cada tecla, falta la espera (debounce).
  • Pestaña Red, filtrando por el dominio de tu API. Distingue lo que pide el navegador (TanStack Query) de lo que pide el servidor (que no aparece aquí). Para lo segundo, mira el registro del servidor o usa un envoltorio como api-registro.mjs.
  • React DevTools. En la pestaña Components puedes ver el useState de cada componente y el valor de cada proveedor de Context. Las rutas que <Activity> mantiene ocultas también aparecen.
  • TanStack Query Devtools. El paquete @tanstack/react-query-devtools muestra cada clave, su estado (fresca, caducada, pidiendo) y su dataUpdatedAt. Así se habría visto enseguida por qué el resumen se volvía a pedir (apartado 8.7). Lo tienes explicado en el módulo 19 del Curso de React.
  • Pestaña Aplicación, Almacenamiento local. Ahí está la clave empresa-carrito con el JSON que guarda persist. Borrarla es la forma rápida de probar el carrito vacío.
  • El registro del servidor. Los avisos de prerenderizado (Date.now(), datos de petición fuera de <Suspense>) a veces no rompen la página, pero aparecen ahí. Míralo después de cada prueba, no solo cuando algo falla.
  • next build con frecuencia. Los errores de useSearchParams sin <Suspense> y de Date.now() en páginas estáticas solo aparecen al compilar.

11. Errores frecuentes

Síntoma Causa Solución
next build falla: “encountered URL data useSearchParams() in a Client Component outside of <Suspense>” Un Client Component lee la URL y nada por encima dice qué mostrar en el prerender Envuélvelo en <Suspense>, lo más abajo posible (apartado 4.5)
En next dev funciona y en next build falla En desarrollo siempre hay una URL real Compila a menudo
Cada tecla en el buscador lanza una petición router.replace en cada onChange Espera de 300 ms (debounce) antes de navegar
Atrás sale de la página en vez de deshacer el filtro Se usó replace Usa push si el usuario querrá deshacer el cambio
El desplegable no refleja la URL tras pulsar Atrás Campo no controlado con defaultValue key con la cadena de consulta, o un efecto que actualice el valor
El campo de búsqueda pierde el foco al escribir La key del campo cambia con la URL que tú mismo modificas No uses la URL como key del campo en el que se escribe
El Server Component no ve el filtro nuevo Se cambió la URL con history.pushState/replaceState Usa router.push/replace si el servidor debe enterarse
searchParams llega con string[] y el código falla El parámetro aparece dos veces en la URL Normaliza con una función como leerFiltros
La caché del servidor crece sin parar Se pasa el objeto searchParams entero a una función 'use cache' Pasa solo los valores validados que necesitas
Los resultados no se ven con JavaScript desactivado En una página ◐ el contenido de <Suspense> necesita un script para mostrarse Asúmelo o pon esa información en el armazón
Un usuario ve datos de otro Un store de módulo (Zustand) o un QueryClient de módulo se escribe en el servidor Store por petición (apartado 7.4); QueryClient nuevo en cada render del servidor
“Carrito: 0” un instante antes del valor real localStorage solo existe en el navegador Normal; muestra un estado de carga con hasHydrated o usa una cookie
prefetchQuery provoca “encountered the unstable value Date.now()” TanStack Query lee la hora, prohibida al prerenderizar await connection() antes, o el ayudante deshidratar (apartados 8.6 y 8.7)
El navegador vuelve a pedir datos que llegaron hidratados dataUpdatedAt es antiguo (prerender) y supera staleTime staleTime mayor o Infinity con invalidación por etiqueta
Tras una mutación, la pantalla se actualiza pero al recargar sale el dato viejo Solo se invalidó la caché del navegador updateTag en la Server Action además de invalidateQueries
Tras una mutación, otras listas siguen viejas Solo se refrescó la página actual (updateTag) invalidateQueries con el prefijo de la clave
useQuery vuelve a pedir al volver a una página <Activity> limpia las suscripciones al ocultar, y al volver los datos están caducados Es lo esperado; sube staleTime si no quieres la petición
Error de build por importar server-only desde un Client Component El contrato de caché importa funciones de servidor Contrato sin importaciones de servidor; la queryFn de servidor se pasa al precargar

12. Ejercicios

Ejercicio 1: Un hook useParametro

Escribe un hook useParametro(nombre, porDefecto) que funcione como useState, pero guardando el valor en la cadena de consulta: devuelve [valor, cambiar]. Si el valor nuevo es el de por defecto, el parámetro debe desaparecer de la URL. Úsalo en una página con un selector de moneda (EUR, USD, GBP).

Ver solución
// lib/use-parametro.ts
'use client'

import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback } from 'react'

// Lee y escribe un parámetro de la URL como si fuera un useState
export function useParametro(nombre: string, porDefecto = '') {
  const parametros = useSearchParams()
  const ruta = usePathname()
  const router = useRouter()
  const valor = parametros.get(nombre) ?? porDefecto

  const cambiar = useCallback(
    (nuevo: string) => {
      const p = new URLSearchParams(parametros)
      if (nuevo === porDefecto) p.delete(nombre)
      else p.set(nombre, nuevo)
      const texto = p.toString()
      router.replace(texto ? `${ruta}?${texto}` : ruta, { scroll: false })
    },
    [parametros, ruta, router, nombre, porDefecto],
  )

  return [valor, cambiar] as const
}
// app/ejercicios/e1/page.tsx
'use client'

import { Suspense } from 'react'
import { useParametro } from '@/lib/use-parametro'

function Selector() {
  const [moneda, setMoneda] = useParametro('moneda', 'EUR')
  return (
    <label>
      Moneda:{' '}
      <select value={moneda} onChange={(e) => setMoneda(e.target.value)} data-testid="moneda">
        <option>EUR</option>
        <option>USD</option>
        <option>GBP</option>
      </select>{' '}
      <span data-testid="valor">{moneda}</span>
    </label>
  )
}

export default function Pagina() {
  return (
    <Suspense fallback={<p>Cargando…</p>}>
      <Selector />
    </Suspense>
  )
}

Resultado al elegir USD y después volver a EUR:

e1: /ejercicios/e1?moneda=USD USD
e1 por defecto: /ejercicios/e1

Aquí el <select> puede ser controlado (value) sin problemas: no hay escritura continua, y el valor sale siempre de la URL, que es la única fuente de verdad. La página es un Client Component entero, pero necesita <Suspense> igualmente: sin él, el build fallaría (apartado 4.5). La página compila como ○.

Ejercicio 2: Pestañas de un pedido en la URL

Crea /ejercicios/e2/[id], que muestre un pedido de la API con dos pestañas: “Líneas” (por defecto) y “Cliente”. La pestaña activa debe estar en la URL (?pestana=cliente), resolverse en el servidor y cambiarse con enlaces, sin ningún Client Component.

Ver solución
// app/ejercicios/e2/[id]/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'

const PESTANAS = { lineas: 'Líneas', cliente: 'Cliente' } as const
type Pestana = keyof typeof PESTANAS

type Linea = { numLinea: number; producto: string; cantidad: number; importe: number }
type PedidoCompleto = { pedidoId: number; cliente: string; clienteId: number; estado: string; lineas: Linea[] }

const API = process.env.API_URL ?? 'http://localhost:3000/api'

export default function Pagina({ params, searchParams }: PageProps<'/ejercicios/e2/[id]'>) {
  return (
    <Suspense fallback={<p>Cargando pedido…</p>}>
      <Ficha params={params} searchParams={searchParams} />
    </Suspense>
  )
}

async function Ficha({ params, searchParams }: Pick<PageProps<'/ejercicios/e2/[id]'>, 'params' | 'searchParams'>) {
  const [{ id }, { pestana }] = await Promise.all([params, searchParams])
  const activa: Pestana = pestana === 'cliente' ? 'cliente' : 'lineas'
  const r = await fetch(`${API}/pedidos/${id}`)
  if (!r.ok) return <p>No existe el pedido {id}.</p>
  const pedido: PedidoCompleto = await r.json()

  return (
    <>
      <h1 className="mb-3 text-xl font-bold">Pedido {pedido.pedidoId}</h1>
      <nav className="mb-3 flex gap-2">
        {(Object.keys(PESTANAS) as Pestana[]).map((p) => (
          <Link
            key={p}
            href={{ query: p === 'lineas' ? {} : { pestana: p } }}
            replace
            scroll={false}
            aria-current={p === activa ? 'page' : undefined}
            className={p === activa ? 'font-bold underline' : ''}
          >
            {PESTANAS[p]}
          </Link>
        ))}
      </nav>
      {activa === 'lineas' ? (
        <ul data-testid="contenido">
          {pedido.lineas.map((l) => (
            <li key={l.numLinea}>
              {l.cantidad} × {l.producto}: {l.importe} €
            </li>
          ))}
        </ul>
      ) : (
        <p data-testid="contenido">
          {pedido.cliente} (cliente {pedido.clienteId}), estado {pedido.estado}
        </p>
      )}
    </>
  )
}

Resultado con el pedido 5001:

e2 lineas: 1 × Portátil Gaming 16: 1499 €2 × Ratón inalámbrico: 39.8 €2 × Monitor 24 FHD: 2...
e2 cliente: /ejercicios/e2/5001?pestana=cliente Talleres Martín S.L. (cliente 1001), estado ENTREGADO

Las claves:

  • href={{ query: { pestana: p } }} sin pathname enlaza a la ruta actual con esa cadena de consulta. Con query: {} se quita.
  • params y searchParams se esperan juntos con Promise.all, dentro del componente que está en <Suspense>, no en la página. La ruta compila como ◐.
  • replace evita llenar el historial con cambios de pestaña; si prefieres que Atrás vuelva a la pestaña anterior, quítalo.
  • Todo se resuelve en el servidor: la página no envía JavaScript propio.

Ejercicio 3: Contador de pedidos pendientes

Crea un componente <PedidosPendientes> que muestre “N pendientes” reutilizando la misma entrada de caché que la tabla de pedidos filtrada por PENDIENTE, de modo que si los dos están en pantalla solo haya una petición.

Ver solución
// app/ejercicios/e3/pendientes.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { pedidosCache } from '@/lib/consultas'

// Reutiliza la consulta de la tabla de pedidos: misma clave, misma entrada de caché
export function PedidosPendientes() {
  const { data: pendientes } = useQuery({
    ...pedidosCache.opciones('PENDIENTE'),
    select: (pedidos) => pedidos.length,
  })
  if (pendientes === undefined) return null
  return (
    <span className="rounded-full bg-orange-500 px-2 text-xs text-white" data-testid="pendientes">
      {pendientes} pendientes
    </span>
  )
}
// app/ejercicios/e3/page.tsx
import { PedidosPendientes } from './pendientes'

export default function Pagina() {
  return (
    <p>
      Aviso: <PedidosPendientes />
    </p>
  )
}
e3: 2 pendientes

select transforma los datos para este componente sin cambiar lo que se guarda en la caché: la tabla sigue recibiendo la lista completa y el contador solo el número. Como la clave es la misma (['pedidos', 'PENDIENTE']), TanStack Query hace una sola petición aunque los dos componentes estén montados, y el invalidateQueries({ queryKey: ['pedidos'] }) de la mutación actualiza los dos.

Esta página no lleva <Suspense> y compiló como ○: useQuery no suspende, y durante el prerender devuelve data: undefined, por eso el componente devuelve null hasta que llegan los datos en el navegador.

Ejercicio 4: Carrito sin el “0” falso

La insignia del carrito muestra “Carrito: 0” un instante antes del valor real. Escribe un hook useCarritoHidratado() que devuelva false en el servidor y durante la hidratación, y true cuando persist haya terminado de leer localStorage. Úsalo para mostrar “Leyendo tu carrito…” mientras tanto.

Ver solución
// lib/use-hidratado.ts
'use client'

import { useSyncExternalStore } from 'react'
import { useCarrito } from './carrito'

// true cuando persist ya ha leído localStorage; false en el servidor y en la hidratación
export function useCarritoHidratado(): boolean {
  return useSyncExternalStore(
    (avisar) => useCarrito.persist.onFinishHydration(avisar),
    () => useCarrito.persist.hasHydrated(),
    () => false,
  )
}
// app/ejercicios/e4/page.tsx
'use client'

import { useCarrito, seleccionarUnidades } from '@/lib/carrito'
import { useCarritoHidratado } from '@/lib/use-hidratado'

export default function Pagina() {
  const hidratado = useCarritoHidratado()
  const unidades = useCarrito(seleccionarUnidades)
  return <p data-testid="resumen">{hidratado ? `Tienes ${unidades} unidades` : 'Leyendo tu carrito…'}</p>
}
e4 HTML servidor: true
e4 navegador: Tienes 2 unidades

El HTML del servidor contiene “Leyendo tu carrito…” y el navegador termina mostrando las unidades. useSyncExternalStore es la herramienta de React para leer una fuente externa:

  • El primer argumento se suscribe a los cambios: onFinishHydration de persist avisa cuando termina la lectura, y devuelve la función para cancelar la suscripción.
  • El segundo lee el valor en el navegador: hasHydrated().
  • El tercero es el valor en el servidor y durante la hidratación: false. Así el primer render del cliente coincide con el HTML del servidor, sin error de hidratación.

Funciona porque el store usa skipHydration: true y Providers llama a rehydrate() en un efecto, después de hidratar.

Ejercicio 5: Diagnóstico de un build roto

Un compañero añade una página de búsqueda y el despliegue falla. En su ordenador, con next dev, funciona perfectamente:

// app/buscar/page.tsx
'use client'
import { useSearchParams } from 'next/navigation'

export default function Buscar() {
  const q = useSearchParams().get('q') ?? ''
  return <h1>Resultados de «{q}»</h1>
}

Explica qué pasa, por qué no lo ve en desarrollo y arréglalo de forma que el título “Buscar” aparezca en el armazón estático.

Ver solución

En el build, Next prerenderiza el armazón de cada página y en ese momento no hay URL. useSearchParams no puede dar un valor, y con Cache Components, si no hay un <Suspense> por encima, el build falla con “Next.js encountered URL data useSearchParams() in a Client Component outside of <Suspense>” (apartado 4.5, error real reproducido). En next dev las páginas se renderizan bajo demanda con la URL de la petición, así que nunca falta el valor.

Arreglo: la página pasa a ser un Server Component que pone el título y envuelve solo la parte que lee la URL:

// app/buscar/page.tsx
import { Suspense } from 'react'
import { TextoBuscado } from './texto-buscado'

export default function Buscar() {
  return (
    <h1>
      Buscar{' '}
      <Suspense fallback={<span>…</span>}>
        <TextoBuscado />
      </Suspense>
    </h1>
  )
}
// app/buscar/texto-buscado.tsx
'use client'
import { useSearchParams } from 'next/navigation'

export function TextoBuscado() {
  const q = useSearchParams().get('q') ?? ''
  return <span>«{q}»</span>
}

“Buscar” forma parte del armazón y aparece al instante; el texto buscado llega después. Es el mismo arreglo que se aplicó a los filtros de la tienda. Si los resultados de la búsqueda se piden en el servidor, otra opción mejor es leer searchParams en un Server Component dentro de <Suspense>, como en el apartado 3.2.

Ejercicio 6: Diagnóstico de una fuga entre usuarios

En producción, un usuario ve durante un instante el nombre de otro en la cabecera. El código es este:

// lib/usuario.ts
'use client'
import { create } from 'zustand'
export const useUsuario = create<{ nombre: string }>(() => ({ nombre: '' }))
// app/cabecera.tsx
'use client'
import { useUsuario } from '@/lib/usuario'

export function Cabecera({ nombre }: { nombre: string }) {
  if (!useUsuario.getState().nombre) useUsuario.setState({ nombre })
  const actual = useUsuario((s) => s.nombre)
  return <p>Hola, {actual}</p>
}

Cabecera la renderiza el layout raíz con el nombre del usuario de la sesión. Explica la fuga y propón dos arreglos.

Ver solución

Cabecera es un Client Component, pero también se renderiza en el servidor para generar el HTML. En el servidor, el módulo lib/usuario.ts se carga una sola vez para todas las peticiones, así que useUsuario es un único store compartido por todos los usuarios. La primera petición escribe su nombre con setState; como la condición !getState().nombre ya no se cumple, las siguientes peticiones no lo sobrescriben y renderizan en el HTML el nombre del primer usuario. En el navegador, el store es otro (vacío), así que se escribe el nombre correcto y la página se corrige: por eso se ve “durante un instante”.

Dos arreglos:

  1. No usar un store para esto. El nombre del usuario es un dato del servidor: que Cabecera lo reciba como prop y lo pinte. Si lo necesitan más componentes cliente, un Context con el valor (sin estado) basta.

    export function Cabecera({ nombre }: { nombre: string }) {
      return <p>Hola, {nombre}</p>
    }
    
  2. Store por petición con createStore y Context (apartado 7.4), inicializado con la prop en useState(() => crear({ nombre })). Cada render en el servidor crea su propio store.

La regla general: nunca escribas en un store de módulo durante el render. Escribir en manejadores de eventos o efectos es seguro porque solo ocurre en el navegador.

Ejercicio 7: ¿Dónde va cada estado?

Decide dónde guardarías cada uno de estos estados de EMPRESA y por qué: (a) el departamento elegido en el listado de empleados; (b) si el menú lateral está plegado; (c) los productos de un pedido que se está creando en un asistente de tres pasos; (d) el número de pedidos pendientes que se muestra en la cabecera del administrador y debe estar al día; (e) el idioma de la interfaz; (f) el texto de un comentario a medio escribir; (g) los datos del perfil del usuario.

Ver solución
Estado Dónde Por qué
(a) Departamento del listado URL (?dept=20) leída en el servidor Se comparte con un enlace, funciona con Atrás y decide qué datos se piden
(b) Menú plegado Cookie, o localStorage si no importa el primer píxel Preferencia que debe sobrevivir a recargas; con cookie el servidor pinta el menú correcto desde el principio
(c) Asistente de tres pasos Zustand (o useReducer en un layout común a los tres pasos) Lo usan varias páginas; con persist en sessionStorage sobrevive a una recarga accidental
(d) Pendientes en la cabecera TanStack Query con refetchInterval, precargado o no Debe actualizarse solo, sin navegar (ejercicio 3)
(e) Idioma En la ruta (/es/...) o en una cookie Lo necesita el servidor para renderizar; lo verás en el módulo 19
(f) Comentario a medio escribir useState local Efímero; en Next.js 16 <Activity> lo conserva si el usuario navega y vuelve
(g) Perfil del usuario Servidor (Server Component con la sesión) Es un dato de negocio; no hace falta copiarlo en el cliente

Ejercicio 8: Marcar enviado de forma optimista

Modifica la mutación de la tabla de pedidos para que el pedido cambie a ENVIADO en cuanto se pulsa el botón, sin esperar a la API, y vuelva a su estado anterior si la acción falla.

Ver solución
// app/ejercicios/e8/boton-optimista.tsx
'use client'

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { pedidosCache } from '@/lib/consultas'
import type { Pedido } from '@/lib/tipos'
import { marcarEnviado } from '@/app/gestion/pedidos/acciones'

export function useMarcarEnviadoOptimista(estado: string | null) {
  const queryClient = useQueryClient()
  const clave = pedidosCache.clave(estado)

  return useMutation({
    mutationFn: marcarEnviado,
    onMutate: async (pedidoId: number) => {
      await queryClient.cancelQueries({ queryKey: clave })
      const anterior = queryClient.getQueryData<Pedido[]>(clave)
      queryClient.setQueryData<Pedido[]>(clave, (lista = []) =>
        lista.map((p) => (p.pedidoId === pedidoId ? { ...p, estado: 'ENVIADO' } : p)),
      )
      return { anterior }
    },
    onError: (_error, _id, contexto) => queryClient.setQueryData(clave, contexto?.anterior),
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['pedidos'] }),
  })
}

export function BotonOptimista({ pedidoId, estado }: { pedidoId: number; estado: string | null }) {
  const mutacion = useMarcarEnviadoOptimista(estado)
  return <button onClick={() => mutacion.mutate(pedidoId)}>Marcar enviado</button>
}

En TablaPedidos, sustituye el useMutation por const enviar = useMarcarEnviadoOptimista(estado).

  • cancelQueries detiene cualquier petición en curso de esa clave (por ejemplo, la del refetchInterval), para que no llegue tarde y pise el valor optimista.
  • onMutate guarda la lista anterior y escribe la nueva. Lo que devuelve llega como contexto a onError.
  • onSettled (tanto si va bien como si va mal) invalida todas las listas de pedidos para quedar sincronizado con la API.
  • En la vista de PENDIENTE, el pedido seguirá en la lista con estado ENVIADO hasta que llegue la invalidación, que lo quita. Si prefieres que desaparezca al instante, filtra en onMutate según el estado de la vista.

Next.js también tiene su herramienta para esto, useOptimistic de React con Server Actions (módulo 10); cuando los datos ya viven en TanStack Query, es más natural hacerlo en su caché como aquí.

La moneda preferida del usuario se guarda en la cookie moneda. Crea un store de Zustand por petición, inicializado en el servidor con el valor de la cookie, y un selector que la cambie en el cliente.

Ver solución

El proveedor y el hook son los del apartado 7.4 (app/ejercicios/e9/preferencias.tsx), con un selector:

// app/ejercicios/e9/preferencias.tsx (añadir al final)
export function SelectorMoneda() {
  const moneda = usePreferencias((s) => s.moneda)
  const cambiar = usePreferencias((s) => s.cambiarMoneda)
  return (
    <select value={moneda} onChange={(e) => cambiar(e.target.value)} data-testid="moneda">
      <option>EUR</option>
      <option>USD</option>
    </select>
  )
}
// app/ejercicios/e9/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { PreferenciasProvider, SelectorMoneda } from './preferencias'

export default function Pagina() {
  return (
    <Suspense fallback={<p>Cargando preferencias…</p>}>
      <ConPreferencias />
    </Suspense>
  )
}

async function ConPreferencias() {
  const moneda = (await cookies()).get('moneda')?.value ?? 'EUR'
  return (
    <PreferenciasProvider moneda={moneda}>
      <SelectorMoneda />
    </PreferenciasProvider>
  )
}

Con la cookie moneda=USD en el navegador:

e9 con cookie USD: USD

cookies() es un dato de petición, así que se lee dentro de <Suspense> y la página compila como ◐. El valor correcto aparece desde el primer HTML, sin parpadeo, porque el servidor lo conoce: es la ventaja de la cookie frente a localStorage. Cada petición crea su propio store en useState(() => ...), así que dos usuarios con monedas distintas nunca se mezclan. Para que el cambio sobreviva a la recarga, la acción cambiarMoneda debería además escribir la cookie (con document.cookie o una Server Action).

Ejercicio 10 (propuesto): Catálogo con scroll infinito

Convierte la lista de la tienda en un scroll infinito con useInfiniteQuery: la primera página de productos debe precargarse en el servidor (con la misma queryKey que usará el cliente y respetando los filtros de la URL) y las siguientes se piden al llegar al final de la lista. Piensa qué pasa con la clave cuando cambian los filtros y si sigue teniendo sentido el parámetro pagina en la URL.

Ejercicio 11 (propuesto): Vista que sobrevive a recargar

La vista tarjetas/lista de la tienda se pierde al recargar (apartado 6.2). Guárdala en una cookie vista: el layout de la tienda debe leerla en el servidor y pasarla como valor inicial a VistaProvider, y el botón debe actualizar la cookie al cambiar. Comprueba con JavaScript desactivado que el HTML del servidor ya trae la vista correcta, y decide dónde necesitas un <Suspense>.


Resumen

Concepto API Idea clave
Estado de servidor Server Components, 'use cache' Los datos de negocio no necesitan almacén en el cliente
URL como estado searchParams (promesa) Se comparte, sobrevive y la lee el servidor; valídala siempre
Caché por filtros 'use cache' con argumentos Una entrada por combinación; pasa valores normalizados
Leer la URL en el cliente useSearchParams() Solo lectura; copia a URLSearchParams para modificar
Escribir la URL router.replace, router.push, <Link>, <Form> Petición RSC; replace no deja rastro en el historial
URL sin servidor history.pushState, replaceState useSearchParams se actualiza; los Server Components no
Suspense obligatorio <Suspense> alrededor de useSearchParams Sin él, el build falla con Cache Components
Sin JavaScript Huecos de una página ◐ El contenido llega oculto y necesita un script para mostrarse
Estado al navegar <Activity> (Next.js 16) Conserva useState; la URL la decide el enlace
Context Proveedor 'use client' con children Los hijos siguen siendo Server Components
Zustand create, persist, skipHydration Store de módulo solo si nunca se escribe en el servidor
Store por petición createStore + Context + useState Inicializa con datos del usuario sin mezclarlos
Hidratación de persist getInitialState en Zustand 5 Sin error de hidratación; el “0” inicial es normal
Proveedor de TanStack Query QueryClient nuevo en el servidor, único en el navegador Nunca compartir caché entre usuarios
Contrato de caché queryOptions + etiqueta Misma clave en servidor y cliente; sin importaciones de servidor
Precarga prefetchQuery, dehydrate, HydrationBoundary Primer HTML con datos y cero peticiones del navegador
La hora y Cache Components await connection() o hora cacheada TanStack Query lee Date.now(), prohibido al prerenderizar
Mutaciones Server Action + updateTag + invalidateQueries Dos cachés, dos invalidaciones