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

Deep linking y compartir: el contrato de la plataforma

Un estado que vive en la URL hereda tres afordancias que el usuario trae de fábrica: es compartible por enlace, navegable con los botones atrás y adelante, y marcable en favoritos. Esta lección explica que esas afordancias no se construyen sino que se heredan, disecciona la pila del historial como un registro de deshacer que la plataforma te regala, fija la higiene entre pushState y replaceState, presenta el deep linking como característica de producto con dividendos en SSR y analítica, y advierte que un enlace compartido se convierte en un contrato hacia el pasado.

⏱ 17 min

Cuando un estado vive en la URL, el usuario recibe tres capacidades sin que tú programes ni una: puede compartir la vista pegando el enlace en un chat, puede recorrer sus vistas anteriores con el botón atrás y rehacerlas con el de adelante, y puede guardar en favoritos un estado concreto para volver semanas después. Estas tres afordancias —compartir, navegar, marcar— no son funciones que tu aplicación construye, sino expectativas que el usuario importa de la plataforma. Ese matiz lo cambia todo. No estás añadiendo una característica: estás decidiendo si honras o rompes un contrato que el navegador ya firmó con el usuario mucho antes de que tu código existiera. Romperlo guardando los filtros en memoria no produce un error visible; produce una interfaz que se siente sutilmente rota, hostil de un modo que cuesta nombrar.

🎯 Al terminar esta lección sabrás
  • Entender las tres afordancias del contrato: compartir, navegar y marcar, como algo heredado, no construido.
  • Leer la pila del historial como un registro de deshacer que la plataforma regala gratis.
  • Aplicar la higiene entre pushState y replaceState según el peso de cada cambio.
  • Reconocer que un enlace compartido se vuelve un contrato que no puedes romper impunemente.

Tres afordancias que no construyes, heredas

La palabra clave es afordancia: una posibilidad de acción que el usuario percibe y espera antes de tocar tu producto. El botón atrás, la barra de favoritos y el gesto de copiar y pegar un enlace son afordancias del navegador, no de tu aplicación, y el usuario las trae aprendidas de las mil páginas que visitó antes que la tuya. Cuando pones el estado navegable en la URL, esas tres afordancias empiezan a funcionar sobre tu estado sin que escribas una línea, porque el navegador ya sabe compartir texto, apilar historial y guardar direcciones. Cuando lo pones en un useState, las tres siguen ahí en la interfaz —el botón atrás no desaparece— pero mienten: prometen una acción que ya no hace lo que el usuario espera.

🔗

Compartible

Copiar y pegar el enlace reproduce exactamente la misma vista en otra máquina. El estado viaja en el texto, sin sesión ni cuenta compartida.

⏮️

Navegable

Atrás y adelante recorren la pila del historial. Cada cambio de vista es un punto al que volver, con gestos que el usuario ya domina.

Marcable

Guardar en favoritos captura un estado concreto, no solo una página. El usuario vuelve semanas después a la vista exacta que dejó.

Ese es el daño preciso de guardar estado navegable en memoria: no rompe una función que añadiste, rompe una promesa que el navegador hizo por ti. El usuario pulsa atrás esperando volver al filtro anterior y sale de la página entera; comparte el enlace y su compañero ve la vista por defecto en lugar de la filtrada; guarda en favoritos y al volver encuentra el estado inicial. Ninguno de esos fallos lanza un error ni aparece en un log. Es deuda de interfaz: invisible para las herramientas, palpable para la persona.

ℹ️
El coste no lo paga quien lo crea

La perversidad de romper el contrato de la plataforma es que el coste está diferido y desplazado. Quien guarda los filtros en useState no sufre nada: en su máquina, recién cargada la página, todo parece correcto. El coste lo paga el usuario que copia un enlace para un colega, el que vuelve a un favorito, el que pulsa atrás por instinto. Como el fallo no aparece en desarrollo ni en los tests que solo montan el componente, sobrevive hasta producción disfrazado de decisión inocente. Por eso el criterio de la lección 1 debe aplicarse por adelantado: cuando ya hay un useState, nadie vuelve a preguntarse si ese estado era navegable.

La pila del historial es un registro de deshacer gratis

Detrás del botón atrás hay una estructura que conviene ver con claridad: una pila de entradas de historial, cada una con su URL, que el navegador mantiene por ti. Atrás y adelante mueven un puntero por esa pila; cada navegación a una URL nueva empuja una entrada encima. Puesto en términos de estado, el historial es un registro de deshacer y rehacer, persistente y controlado por el usuario, que obtienes gratis por el mero hecho de escribir tus cambios de vista en la URL. Construir eso a mano —un undo stack de filtros, con su puntero y su serialización— es trabajo real que la plataforma ya hizo.

flowchart LR
A[color azul] -->|pushState| B[color azul y talla m]
B -->|pushState| C[color azul talla m pagina 2]
C -.atras.-> B
B -.atras.-> A
A -.adelante.-> B
style A fill:#89b4fa,color:#11111b
style B fill:#a6e3a1,color:#11111b
style C fill:#cba6f7,color:#11111b

La consecuencia de diseño es que tú decides qué merece una entrada en esa pila, y ahí entra la higiene del historial. Al escribir en la URL eliges entre dos operaciones. pushState empuja una entrada nueva: el usuario podrá volver a la anterior con el botón atrás. replaceState reemplaza la entrada actual sin crecer la pila: el cambio no deja rastro que deshacer. La regla es de intención: usa pushState para cambios que el usuario esperaría poder deshacer —aplicar un filtro relevante, cambiar de pestaña, pasar de página— y replaceState para actualizaciones continuas que no merecen una entrada cada una —teclear en un buscador, arrastrar un deslizador, mover un mapa—.

// cambio relevante: el usuario querria poder deshacerlo con el boton atras
function aplicarFiltro(clave: string, valor: string) {
  const p = new URLSearchParams(location.search);
  p.set(clave, valor);
  p.set('page', '1');                          // un filtro nuevo reinicia la pagina
  history.pushState(null, '', `?${p}`);        // deja un punto de retorno
}

// cambio de alta frecuencia: no debe ensuciar la pila con una entrada por evento
function moverDeslizador(valor: number) {
  const p = new URLSearchParams(location.search);
  p.set('precio', String(valor));
  history.replaceState(null, '', `?${p}`);     // reemplaza, no empuja
}
⚠️
El bug del botón atrás secuestrado

Ignorar la distinción produce el fallo más citado del estado en la URL: cada pulsación del buscador hace pushState, y el usuario que teclea “botas” genera cinco entradas de historial. Cuando pulsa atrás para salir de la página, se ve obligado a recorrer bota, bot, bo, b antes de escapar. Su botón atrás quedó secuestrado por tu estado de alta frecuencia. La cura es exacta: los cambios continuos van con replaceState, y solo el valor confirmado —al enviar, o tras un debounce— se promueve con pushState si merece ser un punto de retorno.

Deep linking como característica de producto

Reunidas las tres afordancias, emerge algo mayor que la suma: el deep linking como característica de producto. Un panel de control filtrado, un informe con un rango de fechas concreto, una vista de mapa centrada en un punto, un hilo abierto por una pestaña específica: todos se vuelven enlaces que un equipo comparte en un chat, incrusta en un documento o pega en un ticket. Lo que para el usuario es “mándame ese enlace” es, por dentro, estado serializado en la URL, y ninguna otra clase de estado habilita esa colaboración sin construir infraestructura a medida. La compartibilidad deja de ser una casilla técnica y se convierte en una vía por la que tu producto se difunde solo.

// compartir es leer la URL actual: el estado ya esta serializado ahi
async function compartirVista() {
  const url = location.href;                   // la vista entera, sin serializar nada extra
  if (navigator.share) await navigator.share({ url });
  else await navigator.clipboard.writeText(url);
}

Lo revelador de ese fragmento es lo poco que hace: compartir no exige recolectar el estado disperso por varios useState ni serializarlo a un formato propio, porque el estado navegable ya está serializado en location.href. Cuando la vista vive en la URL, “compartir” es leer una cadena que ya existe; cuando vive en memoria, “compartir” es un proyecto de serialización a medida que casi nadie acomete. La diferencia entre ambas realidades es exactamente la diferencia de haber ubicado bien el estado desde el principio.

Hay dos dividendos que redondean el argumento. El primero es el SSR: el servidor no ve tu store ni tu useState, porque se ejecuta antes de que el navegador hidrate nada, pero sí ve la URL, porque llega en la petición. Si los filtros viven en la URL, la página se sirve ya filtrada, sin parpadeo ni salto de contenido; si viven en memoria de cliente, el servidor pinta la vista por defecto y hay que corregirla tras hidratar, con el consiguiente destello. El segundo es la analítica: como cada filtro, búsqueda y pestaña queda escrito en una URL, tu herramienta de medición los captura gratis, sin instrumentar un evento por interacción.

💡
Los dividendos aparecen solos, no se priorizan

SSR correcto, indexabilidad por buscadores y analítica de facetas no son extras que un equipo planifica en un sprint aparte: caen por gravedad en cuanto la vista vive en una URL. Nadie presupuesta “hacer los filtros medibles” cuando cada filtro ya es una URL registrable. Esa es la marca de una decisión de arquitectura acertada: no suma trabajo, retira trabajo futuro que ni siquiera habías contado.

La estabilidad del enlace: un contrato hacia el pasado

Compartir tiene una cara que casi nunca se anticipa: en el instante en que un usuario pega tu URL en un documento, un chat o un correo, esa URL se convierte en una interfaz pública con un contrato implícito de permanencia. El enlace vivirá en sitios que no controlas y se abrirá dentro de meses o años. A partir de ahí, el formato de tus search params ya no es un detalle interno que puedes refactorizar a voluntad: renombrar q a query, cambiar el separador de una lista o alterar el significado de un valor rompe, en silencio, todos los enlaces que alguien compartió antes del cambio.

Esto reencuadra el diseño de los parámetros como diseño de API, con las mismas obligaciones. Elige nombres de parámetros estables y descriptivos desde el principio, porque cambiarlos después es un cambio incompatible. Cuando la evolución sea inevitable, no borres el parámetro viejo: acéptalo en el parseo y redirígelo o tradúcelo al nuevo, igual que mantendrías una ruta antigua con una redirección permanente. Y prefiere siempre una URL canónica —una sola forma textual por estado— para que los enlaces compartidos, la caché y la analítica coincidan en lugar de multiplicarse en variantes.

📝
Renombrar un parámetro orfana cada enlace ya compartido

El fallo es traicionero porque no aparece en tu aplicación: tus formularios generan los nombres nuevos y todo funciona en tus pruebas. Lo que se rompe está fuera —el enlace que un cliente guardó en un favorito, el que un compañero pegó en un ticket hace un año— y falla abriendo la vista por defecto sin avisar a nadie. Trata cada search param compartible como parte de tu superficie pública: versiónalo con cuidado, deprécialo con una traducción, y no lo elimines sin una ruta de compatibilidad.

Compartir, navegar y marcar son un contrato firmado antes que tu código

La lección profunda del deep linking es que las capacidades más valiosas de la web no las construyes tú: las heredas por respetar un contrato que el navegador firmó con el usuario años antes de que tu aplicación naciera. Compartir un enlace, volver atrás, guardar en favoritos: son afordancias universales que el usuario trae aprendidas, y tu único trabajo es no romperlas. Pero romperlas es exactamente lo que ocurre, en silencio, cada vez que un estado que describe una vista se guarda en memoria en lugar de en la URL. La interfaz sigue mostrando el botón atrás y la estrella de favoritos, así que nada parece mal; pero ambos mienten, porque operan sobre un estado que ya no está donde el navegador puede alcanzarlo. Y aquí está el giro que separa al ingeniero del artesano: entender que el historial es un registro de deshacer y rehacer que la plataforma te regala, con su pila y su puntero ya implementados, y que tu responsabilidad se reduce a una decisión de higiene —qué cambio merece empujar una entrada con pushState y cuál solo reemplazar la actual con replaceState—. Hay incluso un tercer nivel de madurez: comprender que un enlace, una vez compartido, deja de ser tuyo y se convierte en un contrato hacia el pasado que no puedes romper renombrando parámetros a la ligera. El deep linking, visto así, no es una funcionalidad opcional que se prioriza en un sprint: es lo que ocurre por defecto cuando el estado navegable está en su sitio, con dividendos —SSR, analítica, difusión— que aparecen solos, y con obligaciones —estabilidad del formato— que asumes al aceptar que la URL es pública. La pregunta correcta no es “¿deberíamos hacer esta vista compartible?”, sino “¿por qué esta vista, que describe lo que el usuario mira, no está ya en la URL?”.

⚔️ Recupera las tres afordancias
  1. Toma una vista con estado navegable en memoria y ejecuta las tres pruebas del contrato: comparte el enlace en otra máquina, pulsa atrás y adelante, y guarda en favoritos y vuelve.
  2. Anota, para cada prueba fallida, qué promesa de la plataforma está rompiendo tu implementación actual.
  3. Migra ese estado a la URL y clasifica cada cambio: ¿merece pushState porque el usuario querría deshacerlo, o replaceState porque es de alta frecuencia?
  4. Reproduce a propósito el bug del botón atrás secuestrado con un buscador en pushState, y arréglalo pasando las pulsaciones a replaceState.
  5. Trata tus search params como una API: fija nombres estables y define qué harías para renombrar uno sin orfanar los enlaces ya compartidos.