wandres.dev
ESTADO EN LA URL · el router como estado

Search params: leer, escribir y tipar

La URL es un store honesto pero primitivo: todo lo que guarda es texto. Esta lección domina la API de URLSearchParams para leer y escribir, y sobre todo instala la disciplina que la separa del caos: un codec entre el mundo de los strings y el mundo tipado. Parsear como operación total que nunca lanza, valores por defecto que canonizan la ausencia, la distinción entre parámetro ausente y vacío, las claves repetidas con getAll y la codificación de porcentaje. La ley de Postel aplicada a la barra de direcciones.

⏱ 17 min

La URL es un store honesto pero primitivo. A diferencia de un useState tipado o un store con esquema, todo lo que la barra de direcciones sabe guardar es texto: un número vuelve como "2", un booleano como "true", una lista como una clave repetida. Esta pobreza no es un defecto a esconder, sino la naturaleza del canal, y trabajarla bien exige una disciplina concreta: interponer un codec entre el mundo de los strings, que es donde vive la URL, y el mundo tipado, que es donde vive tu aplicación. Quien no instala esa frontera acaba con Number(params.get('page')) esparcido por veinte componentes, cada uno reinventando qué hacer cuando el valor es null, está mal formado o simplemente no existe. Esta lección convierte esa dispersión en una sola función de parseo y otra de serialización.

🎯 Al terminar esta lección sabrás
  • Manejar la API de URLSearchParams para leer, escribir, añadir y borrar claves.
  • Instalar un codec: una frontera de parseo y serialización entre string y tipo.
  • Escribir parseos totales que nunca lanzan y colapsan la entrada inválida a un valor por defecto.
  • Distinguir parámetro ausente de vacío, y canonizar la URL omitiendo lo que iguala al defecto.

La API cruda y sus asperezas

URLSearchParams es la interfaz estándar sobre la parte de consulta de una URL. Sus métodos son pocos y directos: get devuelve el primer valor de una clave o null; getAll devuelve todos los valores de una clave repetida como array; set reemplaza; append añade sin borrar; delete elimina; y toString reserializa a texto con la codificación de porcentaje ya aplicada. La primera aspereza vive en get: su tipo de retorno es string | null, y ese null es la fuente de la mitad de los errores, porque obliga a decidir, en cada lectura, qué significa que la clave no esté.

const params = new URLSearchParams('?q=botas&tag=agua&tag=goma');

params.get('q');        // "botas"
params.get('page');     // null  — la clave no existe
params.getAll('tag');   // ["agua", "goma"]  — clave repetida

params.set('page', '2');       // ?q=botas&tag=agua&tag=goma&page=2
params.delete('tag');          // borra TODAS las claves 'tag'
params.toString();             // "q=botas&page=2"  — reserializado y codificado
🔎

get devuelve string o null

Solo el primer valor de la clave, o null si no existe. Ese null es la decisión que hay que tomar en cada lectura: qué significa la ausencia.

🧺

getAll para claves repetidas

Una clave puede aparecer varias veces —?tag=a&tag=b— y getAll la devuelve como array. Usar get sobre una clave repetida pierde el resto en silencio.

✍️

set reemplaza, append añade

set deja un único valor; append suma sin borrar. Elegir mal produce claves duplicadas donde querías una, o pisar valores donde querías acumular.

🧵

toString reserializa y codifica

Reconstruye la cadena con codificación de porcentaje aplicada. Es la única vía segura: concatenar a mano corrompe acentos y espacios.

Las demás asperezas conviene conocerlas antes de que muerdan. Una misma clave puede repetirse —?tag=a&tag=b— y recuperarla con get en vez de getAll te da solo la primera silenciosamente. El orden de los parámetros no está garantizado al reconstruir, lo que complica comparar dos URLs por igualdad textual. Y todo pasa por codificación de porcentaje: un espacio se vuelve %20 o +, un acento se expande a varios bytes. URLSearchParams aplica y revierte esa codificación por ti, pero solo si pasas por sus métodos; concatenar strings a mano es la vía rápida al parámetro corrupto.

Ninguna de estas asperezas es un defecto que arreglar: son la textura real de un store de strings, y aparecen sí o sí en cuanto sales del caso feliz. La conclusión no es memorizarlas una a una, sino sacarlas del código de tu vista y confinarlas a un solo lugar —el codec de la sección siguiente— donde se resuelven una vez y no vuelven a molestar.

El codec: una frontera de parseo total

La lección central no es la API, sino dónde ponerla. El error estructural es leer y convertir la URL en cada componente que la necesita; la corrección es definir una única frontera —un codec— que traduce entre el store de texto y el estado tipado de tu dominio. De un lado, un parseo que toma la URL y devuelve un objeto tipado; del otro, una serialización que toma ese objeto y produce la cadena de búsqueda. Todo el resto de la aplicación habla con el tipo, no con el string, y nunca vuelve a ver un params.get suelto.

type Vista = { q: string; page: number; sort: 'precio' | 'novedad' };

function parseVista(search: string): Vista {
  const p = new URLSearchParams(search);
  const pageRaw = Number(p.get('page'));
  const sort = p.get('sort');
  return {
    q: p.get('q') ?? '',
    // parseo total: NaN, negativo o basura colapsan al defecto, nunca lanza
    page: Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1,
    sort: sort === 'precio' || sort === 'novedad' ? sort : 'novedad',
  };
}

La propiedad crítica de parseVista es que es total: no existe ninguna entrada que la haga lanzar una excepción. Un ?page=-3, un ?page=abc, un ?sort=cohete inyectado a mano o un parámetro que un cambio futuro renombró: todos degradan limpiamente a un valor por defecto sensato en lugar de romper la vista. Esto es la ley de Postel aplicada a la URL —sé liberal en lo que aceptas—, y no es opcional: la barra de direcciones es una entrada de usuario tan pública y manipulable como un campo de formulario, y cualquiera puede teclear en ella lo que quiera. Un parseo que confía en el formato es una pantalla en blanco esperando el primer enlace mal copiado.

⚠️
La URL es entrada de usuario no confiable

Todo lo que llega en un search param puede haber sido editado a mano, generado por un enlace viejo, truncado por un cliente de correo o inyectado con intención. Trátalo con la misma desconfianza que el cuerpo de una petición: valida, acota rangos, restringe los enum a su lista y jamás lo interpoles crudo en HTML, en una consulta ni en un dangerouslySetInnerHTML. Un ?redirect= sin validar es una vulnerabilidad de redirección abierta; un ?sort= sin acotar es, en el mejor caso, una vista rota.

Valores por defecto y la canonización

Los valores por defecto hacen dos trabajos, y el segundo casi nadie lo aprovecha. El primero es de lectura: dan sentido a la ausencia, de modo que una URL sin page significa página 1. El segundo es de escritura y produce URLs limpias: al serializar, omite todo parámetro cuyo valor iguale al defecto. Si sort vale novedad y ese es el defecto, no escribas ?sort=novedad; déjalo fuera. El resultado es una URL canónica —una sola representación textual por cada estado lógico— en lugar de un zoo de variantes equivalentes que ensucian el historial, confunden la caché y estropean la analítica.

function serializeVista(v: Vista): string {
  const p = new URLSearchParams();
  if (v.q !== '') p.set('q', v.q);            // omite el string vacio
  if (v.page !== 1) p.set('page', String(v.page)); // omite el defecto
  if (v.sort !== 'novedad') p.set('sort', v.sort);
  const s = p.toString();
  return s ? `?${s}` : '';                    // vista por defecto: URL desnuda
}

Queda una distinción sutil que separa el estado bien modelado del ambiguo: ausente no es lo mismo que vacío. ?q= con la clave presente pero sin valor, y una URL sin la clave q, pueden significar cosas distintas —“búsqueda explícitamente vaciada” frente a “nunca se buscó”— o la misma; lo que no puedes es dejarlo al azar. La regla práctica: si tu dominio no necesita distinguirlos, colapsa ambos al mismo defecto en el parseo y nunca emitas la clave vacía en la serialización. Así el codec se vuelve idempotente —parsear y volver a serializar reproduce la URL canónica— y esa idempotencia es justo lo que la próxima lección necesitará para sincronizar URL y estado sin bucles.

💡
Un codec idempotente es un codec que puedes confiar

La prueba de que tu codec está bien no es que funcione con la URL que tú tecleaste, sino que cumpla dos igualdades. Parsear y volver a serializar debe reproducir la forma canónica —serialize(parse(s)) estable—, y serializar y volver a parsear debe devolver el mismo estado —parse(serialize(v)) igual a v—. Si esas dos igualdades se sostienen, el codec es idempotente: aplicar el viaje de ida y vuelta no deriva. Esa propiedad no es un lujo teórico; es exactamente lo que impide que la sincronización de la próxima lección entre en un bucle, porque garantiza que releer lo que acabas de escribir no produce un cambio espurio.

flowchart LR
URL[URL con search params] -->|parseo total| T[estado tipado]
T -->|render| V[vista]
V -->|interaccion| T2[nuevo estado tipado]
T2 -->|serializacion canonica| URL
style URL fill:#89b4fa,color:#11111b
style T fill:#a6e3a1,color:#11111b
style T2 fill:#a6e3a1,color:#11111b
style V fill:#cba6f7,color:#11111b

Listas y rangos: cuando un valor no basta

Buena parte del estado navegable no es un escalar sino una colección: varias etiquetas seleccionadas, un rango de precio con mínimo y máximo, un conjunto de facetas. La URL admite dos convenciones para las listas, y conviene elegir una y ser coherente. La primera es la clave repetida —?tag=agua&tag=goma—, nativa de URLSearchParams y recuperable con getAll; la segunda es el valor unido —?tag=agua,goma—, más compacto pero que exige partir y volver a unir a mano. La primera suele preferirse porque no colisiona con comas dentro de un valor y porque las capas tipadas la entienden de fábrica.

// lista con clave repetida: getAll y un parseo total que descarta lo invalido
const tags = new URLSearchParams(location.search)
  .getAll('tag')
  .filter((t) => TAGS_VALIDOS.has(t));         // acota a lo conocido

// rango como dos claves independientes, cada una con su defecto y su cota
const min = clampInt(params.get('min'), 0, 1000, 0);
const max = clampInt(params.get('max'), 0, 1000, 1000);

El rango sigue la misma filosofía que un escalar, solo que duplicada: dos claves independientes, min y max, cada una con su parseo total y su defecto, más una validación cruzada que garantice min <= max colapsando el par inconsistente a algo sensato. Meter ambos límites en un solo parámetro con un separador es la tentación que reintroduce el parseo frágil que el codec vino a eliminar, y además complica la canonización, porque ahora hay un formato interno que defender dentro del valor.

El codec es la frontera entre el texto público y el tipo privado

La madurez con la URL no consiste en memorizar URLSearchParams, sino en entender que la barra de direcciones es un store sin tipos, público y manipulable, y que la única defensa civilizada contra esa naturaleza es una frontera explícita: un codec con dos funciones puras, una que parsea texto a tipo y otra que serializa tipo a texto. Todo lo que hay dentro de esa frontera es tu dominio, tipado y confiable; todo lo que hay fuera es texto no confiable que cualquiera pudo escribir. Esa separación resuelve de golpe una familia entera de problemas que, sin ella, se atacan uno a uno y mal. El parseo total elimina las pantallas en blanco por enlaces corruptos, porque la basura colapsa al defecto en lugar de propagar un NaN hasta el render. Los valores por defecto que se omiten al escribir producen URLs canónicas, y la canonicidad no es cosmética: es lo que permite comparar dos vistas por igualdad, cachear por clave estable y leer la analítica sin que cada estado aparezca en cinco disfraces. Y la idempotencia del codec —que parsear y reserializar reproduzca la misma cadena— es la condición técnica que hará posible, en la lección siguiente, mantener la UI y la URL en sincronía sin caer en un bucle infinito. Quien esparce params.get y Number(...) por sus componentes no ha escrito menos código: ha escrito el mismo parseo veinte veces, cada vez con una decisión distinta sobre el null, y ha convertido una frontera en una fuga. La disciplina cabe en una frase: el resto de tu aplicación jamás debería tocar un string de la URL; solo debería hablar con el tipo que el codec le entrega.

⚔️ Escribe el codec de una vista
  1. Define el tipo del estado navegable de una vista real —búsqueda, página, orden, filtros— con enum acotados donde toque.
  2. Escribe parseVista(search) de forma total: ninguna entrada, por absurda que sea, debe lanzar; toda basura colapsa a un defecto.
  3. Escribe serializeVista(v) que omita cada parámetro igual a su defecto, produciendo una URL canónica.
  4. Verifica la idempotencia: parse(serialize(v)) debe devolver v, y serialize(parse(s)) debe devolver la forma canónica de s.
  5. Ataca los casos límite: clave ausente frente a vacía, clave repetida con getAll, y un ?page=-1&sort=inventado tecleado a mano.