Módulo 7: Url As State
Mini-proyecto: la búsqueda de Mercado en la URL
Descripción
Llegó el momento de juntar el módulo entero en una sola pieza aplicada a Mercado. En este mini-proyecto pones toda la búsqueda del storefront en la URL: el término (query), la categoría (category), el orden (sort) y la página (page) dejan de vivir en useState y pasan a los searchParams, con la URL como fuente de la verdad. La entrega es triple: el módulo de estado-en-la-URL —un serialize/parse validado para las cuatro piezas—, el código React real que lo integra con el router (useSearchParams), y la lógica ejecutada en Node que verifica las cuatro propiedades: round-trip exacto, compartir (dos sesiones derivan la misma vista con getVisibleProducts), back/forward sobre la pila del historial, y validación de URLs rotas —cerrado con un scorecard contra useState—. No es una demo de juguete: es la caja URL de Mercado, lista para usar, con cada afirmación medida en una corrida real.
Conexión con el módulo. Es el capstone de la caja URL. Reúne las siete lecciones: la URL como fuente (L2), el serialize/parse (L3), las tres virtudes (L4), qué va en la URL (L5), el parse validado (L6) y la integración con el router (L7). Y cierra la frontera: aquí construyes y verificas el estado-en-la-URL de Mercado con URLSearchParams; la integración a fondo con el router de Next —Server Components, el modelo de servidor— es la guía de nextjs. Con esto terminas el módulo 7, y la caja URL queda cerrada: junto con el carrito (store, M3), el tema (Context, M2) y los productos (React Query, M4-M6), Mercado tiene cada pieza de estado en su caja correcta.
El plan: qué vamos a construir y verificar
El proyecto tiene tres partes, en orden:
- El módulo. Un
serialize/parsepara el estado de la búsqueda{ query, category, sort, page }, con validación (lista blanca desortycategory,pageentero ≥ 1,queryacotado) y defaults omitidos. - La verificación. Cuatro corridas en Node: el round-trip exacto del estado completo; la demo de compartir (dos sesiones, misma vista); back/forward sobre la pila; y la validación de URLs rotas.
- El scorecard. La tabla que contrasta la búsqueda en la URL contra la misma en
useState, propiedad por propiedad —el veredicto que justifica la decisión—.
El árbol y el ciclo
Este es el storefront con la búsqueda en la URL. La SearchBar y los controles navegan (cambian la URL); la ProductList lee la URL y deriva la vista. No hay useState de filtros:
flowchart TD
URL[("URL ?q=mouse&category=peripherals&sort=price&page=2")]
SB["SearchBar / Filters (router.push)"]
PL["ProductList (useSearchParams -> parse -> getVisibleProducts)"]
URL --> PL
SB -- "serialize + router.push" --> URL
PL -. "el usuario cambia un filtro" .-> SB
El ciclo es el del módulo: la URL se parsea a estado, el estado deriva la vista, y cambiar un filtro serializa de vuelta a la URL y navega. La URL manda; la UI la sigue. Todo lo compartible está en el sobre.
El código React real: la búsqueda leída de la URL
Así queda la búsqueda de Mercado con el estado en la URL (lo que el módulo 7 implementa). La ProductList lee y deriva; los controles navegan. El único useState es el borrador del input (lección 5).
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useState } from 'react';
// Lee la URL, deriva la vista. Sin useState de filtros.
function SearchPage() {
const searchParams = useSearchParams();
const router = useRouter();
const state = parse(searchParams.toString()); // parse VALIDADO (leccion 6)
const visible = getVisibleProducts(products, state.query, state.category, state.sort);
// el BORRADOR del input es local; solo lo APLICADO va a la URL (leccion 5)
const [draft, setDraft] = useState(state.query);
function navigate(next) {
router.push('/search' + serialize({ ...state, ...next })); // serialize + push (leccion 7)
}
return (
<>
<form onSubmit={(e) => { e.preventDefault(); navigate({ query: draft, page: 1 }); }}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
</form>
<CategoryFilter value={state.category} onChange={(c) => navigate({ category: c, page: 1 })} />
<SortSelect value={state.sort} onChange={(s) => navigate({ sort: s, page: 1 })} />
<ul>{visible.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
<Pagination page={state.page} onChange={(p) => navigate({ page: p })} />
</>
);
}
Fíjate en tres detalles del módulo: (1) al cambiar query, category o sort, se resetea page: 1 (una búsqueda nueva empieza en la primera página); (2) cada cambio es una navegación (router.push), no un setState; (3) el draft local se promueve a la URL solo al hacer submit. Todo lo demás —qué mostrar— se deriva leyendo la URL.
Ejemplo trabajado: el módulo, verificado
Vamos a correr la verificación completa: el módulo serialize/parse validado, y las cuatro corridas más el scorecard.
'use strict';
const products = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, category: 'peripherals', inStock: true },
{ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, category: 'peripherals', inStock: false },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, category: 'peripherals', inStock: true },
{ id: 'p4', name: 'Laptop Stand', priceCents: 4500, category: 'furniture', inStock: true },
{ id: 'p5', name: 'Desk Lamp', priceCents: 1999, category: 'furniture', inStock: true },
{ id: 'p6', name: 'Gaming Mouse', priceCents: 4599, category: 'peripherals', inStock: true },
];
const formatPrice = (cents) => '$' + (cents / 100).toFixed(2);
// getVisibleProducts: la MISMA de react-fundamentals (M5).
function getVisibleProducts(products, query, category, sort) {
const q = query.trim().toLowerCase();
return products
.filter((p) => p.name.toLowerCase().includes(q))
.filter((p) => category === 'all' || p.category === category)
.sort((a, b) => sort === 'price-desc' ? b.priceCents - a.priceCents : a.priceCents - b.priceCents);
}
const toSortArg = (s) => (s === 'price-desc' ? 'price-desc' : 'price-asc');
// ---- El modulo de estado-en-la-URL de Mercado ----
const SORTS = ['relevance', 'price', 'price-desc'];
const CATEGORIES = ['all', 'peripherals', 'furniture'];
function serialize(state) {
const p = new URLSearchParams();
if (state.query) p.set('q', state.query);
if (state.category && state.category !== 'all') p.set('category', state.category);
if (state.sort && state.sort !== 'relevance') p.set('sort', state.sort);
if (state.page && state.page !== 1) p.set('page', String(state.page));
const qs = p.toString();
return qs ? '?' + qs : '';
}
function parse(search) {
const p = new URLSearchParams(search);
const sortRaw = p.get('sort') || 'relevance';
const catRaw = p.get('category') || 'all';
const pageRaw = Number.parseInt(p.get('page') || '1', 10);
return {
query: (p.get('q') || '').trim().slice(0, 64),
category: CATEGORIES.includes(catRaw) ? catRaw : 'all',
sort: SORTS.includes(sortRaw) ? sortRaw : 'relevance',
page: Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1,
};
}
const view = (s) => getVisibleProducts(products, s.query, s.category, toSortArg(s.sort))
.map((p) => `${p.name} ${formatPrice(p.priceCents)}`);
const show = (s) => `{ query: ${JSON.stringify(s.query)}, category: ${JSON.stringify(s.category)}, ` +
`sort: ${JSON.stringify(s.sort)}, page: ${s.page} }`;
console.log('=== Mini-proyecto M7: la busqueda de Mercado en la URL ===\n');
// 1) round-trip exacto del estado completo de la busqueda
console.log('--- 1) round-trip del estado de la busqueda ---\n');
const state = { query: 'mouse', category: 'peripherals', sort: 'price', page: 2 };
const url = serialize(state);
console.log(' estado: ' + show(state));
console.log(' serialize -> ' + url);
console.log(' parse -> ' + show(parse(url)));
console.log(' round-trip exacto: ' + (JSON.stringify(parse(url)) === JSON.stringify(state)));
// 2) compartir: dos sesiones -> misma vista via getVisibleProducts
console.log('\n--- 2) compartir el link "mouse ordenado por precio" ---\n');
const link = '?q=mouse&sort=price';
const a = parse(link), b = parse(link);
console.log(' link compartido: https://mercado.app/search' + link);
console.log(' sesion A deriva: [' + view(a).join(', ') + ']');
console.log(' sesion B deriva: [' + view(b).join(', ') + ']');
console.log(' misma vista: ' + (JSON.stringify(view(a)) === JSON.stringify(view(b))));
// 3) back/forward sobre la pila del historial
console.log('\n--- 3) back/forward (historial = pila de URLs) ---\n');
const history = ['', '?q=mouse', '?q=mouse&category=peripherals', '?q=mouse&category=peripherals&sort=price'];
let cursor = history.length - 1;
history.forEach((h, i) => console.log(' [' + i + '] "' + h + '"' + (i === cursor ? ' <- estas aqui' : '')));
cursor--;
console.log(' back -> [' + cursor + ']: ' + show(parse(history[cursor])));
cursor++;
console.log(' forward -> [' + cursor + ']: ' + show(parse(history[cursor])));
// 4) validacion: URLs rotas -> estado limpio
console.log('\n--- 4) validacion de URLs rotas ---\n');
['?page=abc', '?sort=hax', '?category=zzz&page=-3'].forEach((u) => {
console.log(' ' + ('"' + u + '"').padEnd(24) + ' -> ' + show(parse(u)));
});
// 5) scorecard: URL vs useState
console.log('\n--- 5) scorecard: la busqueda en la URL vs en useState ---\n');
console.log(' Propiedad | useState (memoria) | URL (searchParams)');
console.log(' ------------------------|--------------------|-------------------');
console.log(' sobrevive el reload | NO | SI');
console.log(' compartible por link | NO | SI');
console.log(' bookmarkeable | NO | SI');
console.log(' back/forward deshace | NO | SI');
console.log(' validable al entrar | n/a (no hay input) | SI (parse valida)');
console.log('\nEntrega: el modulo serialize/parse (validado), el codigo React (useSearchParams),');
console.log('y esta corrida en Node. La busqueda de Mercado ahora vive en su caja correcta: la URL.');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Mini-proyecto M7: la busqueda de Mercado en la URL ===
--- 1) round-trip del estado de la busqueda ---
estado: { query: "mouse", category: "peripherals", sort: "price", page: 2 }
serialize -> ?q=mouse&category=peripherals&sort=price&page=2
parse -> { query: "mouse", category: "peripherals", sort: "price", page: 2 }
round-trip exacto: true
--- 2) compartir el link "mouse ordenado por precio" ---
link compartido: https://mercado.app/search?q=mouse&sort=price
sesion A deriva: [Wireless Mouse $25.99, Gaming Mouse $45.99]
sesion B deriva: [Wireless Mouse $25.99, Gaming Mouse $45.99]
misma vista: true
--- 3) back/forward (historial = pila de URLs) ---
[0] ""
[1] "?q=mouse"
[2] "?q=mouse&category=peripherals"
[3] "?q=mouse&category=peripherals&sort=price" <- estas aqui
back -> [2]: { query: "mouse", category: "peripherals", sort: "relevance", page: 1 }
forward -> [3]: { query: "mouse", category: "peripherals", sort: "price", page: 1 }
--- 4) validacion de URLs rotas ---
"?page=abc" -> { query: "", category: "all", sort: "relevance", page: 1 }
"?sort=hax" -> { query: "", category: "all", sort: "relevance", page: 1 }
"?category=zzz&page=-3" -> { query: "", category: "all", sort: "relevance", page: 1 }
--- 5) scorecard: la busqueda en la URL vs en useState ---
Propiedad | useState (memoria) | URL (searchParams)
------------------------|--------------------|-------------------
sobrevive el reload | NO | SI
compartible por link | NO | SI
bookmarkeable | NO | SI
back/forward deshace | NO | SI
validable al entrar | n/a (no hay input) | SI (parse valida)
Entrega: el modulo serialize/parse (validado), el codigo React (useSearchParams),
y esta corrida en Node. La busqueda de Mercado ahora vive en su caja correcta: la URL.
Lee la verificación de principio a fin, porque es el módulo entero aplicado a Mercado.
Parte 1 — el round-trip del estado completo. El estado de la búsqueda con las cuatro piezas —{ query: "mouse", category: "peripherals", sort: "price", page: 2 }— se serializa a ?q=mouse&category=peripherals&sort=price&page=2 y vuelve idéntico (round-trip exacto: true). Esa exactitud, ahora con category incluida, es la licencia para que la URL sea la fuente de la verdad de toda la búsqueda, no solo de un par de campos.
Parte 2 — compartir. El link ?q=mouse&sort=price lo abren dos sesiones y las dos derivan la misma vista —Wireless Mouse $25.99, Gaming Mouse $45.99, los dos mouse por precio— con getVisibleProducts (misma vista: true). Este es el "compartir mouse ordenado por precio" que la presentación prometió, ahora cerrado: el link contiene la búsqueda, y cualquiera la reconstruye.
Parte 3 — back/forward. La pila del historial muestra cómo el usuario fue apilando filtros: home → ?q=mouse → +category=peripherals → +sort=price. Está en [3]. "Atrás" lo lleva a [2] (sort: "relevance" —quita el orden, conserva búsqueda y categoría—), "adelante" repone [3] (sort: "price"). Cada estado es una URL de la pila; moverse en el historial restaura estados, gratis.
Parte 4 — validación. Las tres URLs rotas —?page=abc (no numérica), ?sort=hax (fuera de lista blanca), ?category=zzz&page=-3 (categoría inexistente + página negativa)— todas producen un estado limpio: la basura se reemplaza por defaults (page: 1, sort: "relevance", category: "all"). El parse validado (lección 6) garantiza que ninguna URL, por rota o maliciosa que sea, mete basura al estado.
Parte 5 — el scorecard resume el veredicto en cinco filas, y todas cuentan la misma historia: la búsqueda en useState no sobrevive el reload, no se comparte, no se guarda en favoritos, no responde al botón "atrás", y ni siquiera tiene un input que validar; la búsqueda en la URL cumple las cinco. No es una opinión: cada "SI" de la columna URL lo mediste en las partes 1 a 4. La búsqueda de Mercado vive ahora en su caja correcta.
Errores comunes
Poner la búsqueda en la URL pero dejar el borrador ahí también. Qué pasa: se navega en cada tecla del input, no solo al aplicar. Por qué pasa: se implementó "la búsqueda en la URL" sin distinguir borrador de aplicado. Cómo detectarlo: el historial se llena (una entrada por letra) y "atrás" borra letras en vez de deshacer la búsqueda. Cómo corregirlo: el draft del input es useState local (lección 5); solo el query aplicado (submit) se navega a la URL. En el código React del proyecto, esa es la línea navigate({ query: draft }) dentro del onSubmit, no dentro del onChange.
Olvidar resetear la página al cambiar un filtro. Qué pasa: el usuario está en la página 5, cambia la búsqueda, y sigue en la página 5 —que quizás ya no tiene resultados—. Por qué pasa: se navega cambiando solo el filtro, arrastrando el page viejo. Cómo detectarlo: cambiar de categoría o de orden deja una lista vacía o descolocada. Cómo corregirlo: al cambiar query, category o sort, resetea page: 1 en la misma navegación (en el proyecto, navigate({ category: c, page: 1 })). Una búsqueda nueva empieza en la primera página.
Integrar el parse ingenuo en vez del validado. Qué pasa: el proyecto usa un parse que confía en la URL, y un link viejo o editado mete page: NaN al estado. Por qué pasa: se copió el parse "feliz" de la lección 3 en vez del robusto de la lección 6. Cómo detectarlo: crashes o listas vacías al abrir links compartidos o con typos. Cómo corregirlo: el parse del proyecto es el validado —lista blanca de sort/category, page entero ≥ 1, query acotado—. La URL es entrada del usuario; el parse que la integra con el router siempre valida.
Ejercicios
Ejercicio 1 — Agrega inStock al módulo. Mercado quiere un filtro "solo disponibles" (inStock, booleano, default false) compartible por link. Escribe las líneas que agregarías a serialize y a parse (respetando defaults omitidos, conversión de tipo y validación), y di en qué caja cae inStock.
Ver solución
inStock es un filtro de la búsqueda: compartible y recuperable → caja URL. En serialize (omite el default false):
if (state.inStock) p.set('inStock', 'true');
En parse (reconstruye el booleano; ausente o cualquier cosa que no sea "true" → false):
inStock: p.get('inStock') === 'true',
Claves: (1) el booleano se guarda como texto ('true') y se reconstruye con === 'true' (no Boolean(...), que daría true para "false"); (2) el default false se omite de la URL, dejando el link limpio; (3) el round-trip sigue exacto. Al ser un filtro compartible, inStock va en la URL junto a query, category, sort y page.
Ejercicio 2 — Predice el share. Con el módulo del proyecto, un usuario comparte ?q=&category=furniture&sort=price-desc. (a) ¿Qué estado parsea? (b) ¿Qué vista deriva (usa el catálogo del ejemplo)? (c) ¿Por qué el q= vacío no rompe nada?
Ver solución
(a) parse('?q=&category=furniture&sort=price-desc') → { query: "", category: "furniture", sort: "price-desc", page: 1 }. q está vacío → query: ""; category es furniture (en la lista blanca) → pasa; sort es price-desc (en la lista blanca) → pasa; page ausente → 1.
(b) getVisibleProducts(products, "", "furniture", "price-desc"): filtra por query: "" (incluye todo), filtra por categoría furniture (deja Laptop Stand $45.00 y Desk Lamp $19.99), ordena descendente por precio → [Laptop Stand $45.00, Desk Lamp $19.99].
(c) El q= vacío no rompe nada porque parse lo trata como el default (query: ""), que significa "sin filtro de búsqueda". Un query vacío es un estado válido —muestra toda la categoría—, no un error. La ausencia y el vacío convergen al mismo default, que es exactamente lo que la disciplina de defaults (lección 3) busca.
Ejercicio 3 — Redacta el veredicto. Escribe, en tres o cuatro frases, el veredicto que le presentarías al equipo para mover la búsqueda de Mercado de useState a la URL. Incluye una virtud medida y la frontera con la guía de nextjs.
Ver solución
Un veredicto posible:
"Hoy la búsqueda de Mercado (query, category, sort, page) vive en useState, así que no se puede compartir por link, no sobrevive a un reload, y el botón 'atrás' no deshace filtros —lo verificamos: el link con estado en useState llega en blanco al otro usuario—. Proponemos moverla a la URL (searchParams): con un serialize/parse validado, la búsqueda se vuelve compartible ('mira mouse ordenado por precio'), bookmarkeable y navegable con back/forward, y el parse sanea cualquier link roto (?page=abc → página 1) para que no rompa la app. El concepto y la mecánica (URLSearchParams) ya están construidos y verificados en Node; la integración con el router (useSearchParams, navegación) sigue el patrón estándar, y su modelo completo del lado del servidor lo cubre la guía de nextjs. El carrito sigue en el store (M3), el tema en Context (M2) y los productos en React Query (M4-M6): solo la búsqueda cambia de caja."
Lo importante: lleva una virtud medida (el link en useState llega en blanco / compartible en la URL), nombra la herramienta (URLSearchParams + router), y marca la frontera (la integración completa del router es nextjs).
Resumen y siguiente paso
En este mini-proyecto pusiste toda la búsqueda de Mercado en la URL —query, category, sort, page— y la verificaste ejecutando el módulo entero: el round-trip exacto del estado completo, la demo de compartir (dos sesiones, misma vista con getVisibleProducts), el back/forward sobre la pila del historial, y la validación de URLs rotas (basura → defaults limpios), cerrado con el scorecard que contrasta la URL contra useState en cinco propiedades. Entregaste las tres piezas: el módulo serialize/parse validado, el código React que lo integra con useSearchParams/useRouter (con el borrador local y el reset de página), y la corrida en Node que lo prueba. La caja URL de Mercado quedó cerrada, y con ella el módulo 7.
Antes de cerrar deberías poder: construir un serialize/parse validado para un estado de búsqueda; integrarlo con el router leyendo y navegando; verificar las cuatro propiedades ejecutándolas; y redactar el veredicto de por qué la búsqueda va en la URL.
Y hacia dónde sigue la guía. Con este módulo terminaste la cuarta y última caja: ya sabes clasificar cada pieza de estado —local, global de cliente, del servidor, de la URL— y manejar cada una con su herramienta. El módulo 8 es el capstone de toda la guía: manejas todo el estado de Mercado en sus cajas correctas a la vez —la búsqueda/filtros en la URL (lo que acabas de construir), el carrito en un store (M3), el tema en Context (M2), y los productos con React Query (caché + stale-while-revalidate + una mutación con invalidación, M4-M6)—. Entregarás la tabla de clasificación del estado, el código React real, y la lógica ejecutada en Node. La búsqueda-en-la-URL de este proyecto es una de las cuatro piezas de ese capstone; ahí las juntas todas.
Recursos
- Next.js, "useSearchParams" — nextjs.org/docs/app/api-reference/functions/use-search-params. El hook con el que el código React del proyecto lee la URL; la integración que la guía de nextjs cubre a fondo. En inglés.
- MDN, "URLSearchParams" — developer.mozilla.org/en-US/docs/Web/API/URLSearchParams. La API estándar sobre la que está construido el módulo
serialize/parsedel proyecto. En inglés. - TanStack Router, "Search Params" — tanstack.com/router/latest/docs/framework/react/guide/search-params. Un router que trata los
searchParamscomo estado tipado y validado de primera clase; el patrón del proyecto llevado a librería. En inglés. - React, "Choosing the State Structure" — react.dev/learn/choosing-the-state-structure. El principio de una sola fuente de la verdad que justifica mover la búsqueda de
useStatea la URL. En inglés.