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

Sincronizar URL y estado sin bucles

El error más caro del estado en la URL no es de sintaxis sino de arquitectura: mantener el mismo hecho en dos sitios a la vez —la URL y un useState paralelo— y tratar de sincronizarlos con efectos que se disparan mutuamente. Esta lección diagnostica el problema de la doble fuente de verdad como raíz de una familia entera de bugs, prescribe el flujo de una sola dirección donde la URL es canónica y la vista se deriva, explica por qué el bucle infinito es el síntoma de confundir derivación con sincronización, y acota el único caso donde un espejo local es legítimo.

⏱ 18 min

El error más caro del estado en la URL no es sintáctico, es arquitectónico. Aparece en cuanto alguien decide, con la mejor intención, que leer la URL en cada render “es incómodo” y copia su valor a un useState para trabajar más a gusto. En ese instante el mismo hecho —qué filtro está activo— pasa a existir en dos lugares: la barra de direcciones y la memoria del componente. Y dos copias del mismo hecho es, por definición, una invitación a que discrepen. Lo que sigue es predecible: un useEffect que lee la URL y actualiza el estado, otro que observa el estado y reescribe la URL, y entre ambos un bucle que se retroalimenta o, peor, una desincronización intermitente donde el filtro dice una cosa y la lista muestra otra. Esta lección trata de no llegar ahí, y el camino es entender que la sincronización de dos fuentes es el problema, no la solución.

🎯 Al terminar esta lección sabrás
  • Diagnosticar la doble fuente de verdad como raíz de una familia de bugs, no como un caso aislado.
  • Adoptar el flujo de una sola dirección: la URL es canónica y la vista se deriva de ella.
  • Entender por qué el bucle infinito es el síntoma de confundir derivar con sincronizar.
  • Acotar el único caso legítimo de espejo local: la entrada de alta frecuencia con debounce.

La doble fuente de verdad es la enfermedad

Conviene nombrar el problema con precisión, porque mal nombrado se ataca mal. Cuando un dato existe en dos lugares que pretenden ser ambos autoritativos, no tienes dos fuentes de verdad: tienes cero, porque en el momento en que difieren ninguna de las dos es de fiar. La URL dice page=2, el useState dice 3, y no hay regla en el sistema que diga cuál gana; la respuesta depende de qué efecto corrió último, y eso depende del orden de montaje, de un render extra, de una condición de carrera. El bug resultante es intermitente, dependiente del timing e irreproducible: la peor especie.

// ANTIPATRON: dos fuentes de verdad para el mismo hecho
function Listado() {
  const [page, setPage] = useState(1);          // fuente A: memoria

  useEffect(() => {                              // A escucha a B (la URL)
    const p = new URLSearchParams(location.search);
    setPage(Number(p.get('page') ?? '1'));
  }, [location.search]);

  useEffect(() => {                              // B escucha a A
    const p = new URLSearchParams(location.search);
    p.set('page', String(page));
    history.replaceState(null, '', `?${p}`);     // ...que vuelve a disparar el de arriba
  }, [page]);
  // dos efectos que se escuchan mutuamente: bucle o desincronizacion
}

El detalle que vuelve venenoso a este patrón es que no falla siempre. Si por casualidad los dos efectos convergen —el valor leído coincide con el escrito y el segundo efecto deja de disparar—, el componente “funciona”, y funciona precisamente hasta que un render extra, un cambio de orden de montaje o una navegación externa rompen la coincidencia. Un bug que aparece solo bajo cierto timing y desaparece al añadir un console.log es la firma inconfundible de dos fuentes peleándose por el mismo hecho.

Este patrón es tan común que merece un nombre: la sincronización bidireccional. Su marca es tener dos efectos donde cada uno observa lo que el otro escribe. A veces el bucle se estabiliza por casualidad —los valores convergen y el efecto deja de disparar— y entonces “funciona”, lo que es peor que fallar, porque nadie lo arregla hasta que un cambio de timing lo despierta. La lección no es afinar los efectos para que el bucle converja: es eliminar la segunda fuente.

El flujo de una sola dirección

La cura es una reasignación de autoridad, no más código. Se elige una fuente canónica —la URL, porque es la que hereda las propiedades del contrato de la plataforma— y todo lo demás se deriva de ella en lugar de sincronizarse con ella. No hay copia en useState. En cada render, el componente lee la URL y calcula la vista; cuando el usuario interactúa, no actualiza un estado local: escribe directamente en la URL, y ese cambio provoca un nuevo render que vuelve a leer la URL. El ciclo tiene una sola dirección y por eso no puede entrar en bucle.

// PATRON: una sola fuente. La URL manda; la vista se deriva; la interaccion reescribe la URL
function Listado() {
  const page = useMemo(() => {
    const p = new URLSearchParams(location.search);
    const n = Number(p.get('page'));
    return Number.isInteger(n) && n >= 1 ? n : 1;   // derivar, no almacenar
  }, [location.search]);

  function irA(n: number) {
    const p = new URLSearchParams(location.search);
    p.set('page', String(n));
    history.pushState(null, '', `?${p}`);           // escribir, sin setState paralelo
  }
  // no hay useState de page, asi que no hay nada que pueda desincronizarse
}
flowchart TB
subgraph malo[Bidireccional: bucle]
  S[useState page] -->|efecto escribe| U1[URL]
  U1 -->|efecto lee| S
end
subgraph bueno[Unidireccional: estable]
  U2[URL canonica] -->|derivar| V[vista]
  V -->|interaccion escribe| U2
end
style S fill:#f38ba8,color:#11111b
style U1 fill:#f38ba8,color:#11111b
style U2 fill:#89b4fa,color:#11111b
style V fill:#a6e3a1,color:#11111b

La diferencia entre los dos diagramas es exactamente la diferencia entre derivar y sincronizar. Derivar es una función: la vista es f(URL), se recalcula sola cuando la URL cambia y no guarda nada que pueda quedar obsoleto. Sincronizar es un intento de mantener iguales dos cosas que se pueden mover por separado, y por eso necesita efectos que se vigilan mutuamente. El bucle infinito no es un accidente que ocurre a veces en la sincronización: es su naturaleza cuando ambas partes son escribibles. Elimina una de las dos fuentes y no hay nada que sincronizar; solo queda una derivación, que es estable por construcción.

💡
En React 2026 la derivación es el idioma, no la excepción

La regla de oro de los hooks lleva años diciéndolo: si un valor se puede calcular a partir de otro estado, no lo guardes en useState; derívalo en el render, con useMemo si el cálculo pesa. El estado en la URL es el caso más puro de esa regla, con la barra de direcciones haciendo de estado externo. Los routers modernos —los de Next.js, Astro, TanStack— exponen la URL como una fuente reactiva que dispara un nuevo render al cambiar, de modo que “leer la URL en cada render” no es un coste: es el ciclo funcionando como debe.

Derivar no es caro: el mito del render

Una objeción brota siempre al proponer leer la URL en cada render en lugar de copiarla a un estado: ¿no es ineficiente reparsear los search params una y otra vez? La respuesta honesta es que casi nunca importa, y cuando importa la solución no es volver a la doble fuente. Parsear una cadena de consulta corta es trabajo trivial frente al propio render; medir antes de optimizar suele terminar en que no había nada que optimizar. Si el parseo fuera de verdad pesado —un esquema grande, una validación costosa—, se memoiza con useMemo sobre la cadena de búsqueda, de modo que solo se recalcula cuando la URL cambia, sin dejar de derivar.

El otro fantasma es el coste de escribir: ¿no dispara cada pushState una recarga o un refetch? No, si usas routing superficial: escribir la URL sin volver a ejecutar los cargadores de datos que no dependen del parámetro que cambió. Los routers modernos lo hacen por defecto o tras una bandera, y capas como nuqs lo incorporan. El rendimiento, en resumen, no es una excusa para duplicar la fuente de la verdad; es un problema resuelto con memoización en la lectura y routing superficial en la escritura, ambos compatibles con una única fuente canónica.

El único espejo legítimo

Existe una excepción, y precisarla evita tanto el dogma como el abuso. La entrada de texto de alta frecuencia —un buscador que filtra mientras se teclea— sí necesita un estado local, pero no como segunda fuente de verdad, sino como buffer efímero. El input es controlado por un useState que se actualiza en cada tecla para que la caja responda al instante; ese valor no es autoritativo, es un borrador. Solo tras un debounce, o al confirmar, ese borrador se promueve a la URL, que sigue siendo la única fuente canónica. La clave que lo mantiene sano es la unidireccionalidad del compromiso: el buffer escribe a la URL, pero no hay un efecto que lea la URL para reescribir el buffer en cada cambio.

function Buscador({ q }: { q: string }) {   // q viene derivado de la URL
  const [borrador, setBorrador] = useState(q);   // buffer efimero, no autoritativo
  const debounced = useDebounce(borrador, 300);

  useEffect(() => {
    // el buffer se PROMUEVE a la URL; la URL no reescribe el buffer
    const p = new URLSearchParams(location.search);
    debounced ? p.set('q', debounced) : p.delete('q');
    history.replaceState(null, '', `?${p}`);
  }, [debounced]);
}

La diferencia con el antipatrón es sutil pero total, y vale la pena nombrarla: aquí también hay un useState y también hay un efecto que escribe en la URL, pero falta el segundo efecto, el que leería la URL para reescribir el estado. Esa asimetría es lo que rompe el ciclo. El buffer no pretende ser la verdad; es un borrador que fluye en una sola dirección hacia la fuente canónica, y por eso no puede entrar en bucle con ella. Cuando dudes de si un estado local es legítimo, cuenta las flechas: si solo escribe hacia la URL, es un buffer sano; si además la lee para actualizarse, has recreado la doble fuente.

⚠️
Reconciliar el espejo cuando la URL cambia por fuera

El buffer efímero tiene un flanco: si la URL cambia por una vía ajena al teclado —el usuario pulsa atrás, o llega a un enlace con ?q=botas— el borrador local queda desalineado. La reconciliación correcta no es un segundo efecto que sincronice en ambos sentidos, sino resembrar el buffer con la URL solo en esos eventos externos, típicamente con una key que remonte el componente o un efecto que dependa de la navegación, nunca del propio borrador. Si te descubres escribiendo un efecto que copia la URL al buffer en cada cambio, has reintroducido la doble fuente por la puerta de atrás.

Derivar es una función; sincronizar es una promesa que dos partes pueden romper

La idea que ordena todo el problema de la sincronización es que la doble fuente de verdad no es un bug que se corrige, sino una enfermedad que se evita, y que casi todos los bugs de “la UI y la URL discrepan” son la misma enfermedad con distintos síntomas. Cuando el mismo hecho vive en dos lugares que ambos pueden escribir, no existen dos fuentes de verdad: existen cero, porque en el instante en que difieren el sistema no tiene ninguna regla para decidir cuál gana, y la respuesta queda a merced del orden de los efectos, de un render extra o de una condición de carrera. La tentación de arreglarlo con más efectos —uno que lea, otro que escriba— es precisamente lo que crea el bucle, porque dos partes escribibles que se vigilan mutuamente no se estabilizan por diseño, sino por suerte. La salida no es afinar la sincronización, es abolirla: se corona una sola fuente canónica —la URL, que ya trae las propiedades del contrato de la plataforma— y todo lo demás se deriva de ella con una función pura, vista = f(URL), que se recalcula sola y no guarda nada obsoleto. Derivar es estable por construcción porque una función no puede desincronizarse de su entrada; sincronizar es frágil por construcción porque una promesa la pueden romper las dos partes. El único estado local que sobrevive a esta disciplina es el buffer efímero de una entrada de alta frecuencia, y sobrevive solo porque su relación con la URL sigue siendo de una sola dirección: promueve hacia la URL y nunca deja que la URL lo reescriba en cada cambio. Interiorizar esta asimetría —una fuente canónica, muchas derivaciones, cero sincronizaciones bidireccionales— es lo que separa una UI de la URL que funciona por diseño de una que funciona por casualidad hasta que el timing cambia.

⚔️ Convierte una sincronización en una derivación
  1. Encuentra en tu código un estado navegable duplicado en useState y en la URL, y localiza los efectos que los mantienen “en sincronía”.
  2. Fuerza el bug: provoca un cambio rápido o una navegación con el botón atrás y observa la discrepancia o el bucle.
  3. Elimina el useState y deriva el valor de la URL en el render con useMemo; deja que la interacción escriba solo en la URL.
  4. Confirma que ya no existe ningún efecto que lea la URL para reescribir un estado que a su vez reescribe la URL.
  5. Si tienes un buscador, conviértelo en buffer efímero con debounce que promueve a la URL, y resuelve la reconciliación al pulsar atrás sin reintroducir la doble fuente.