Sincronización de la URL con el buscador

Introducción al problema

En esta clase partimos de un buscador que ya tiene:

  • Filtros (tecnología, ubicación, tipo, experiencia)
  • Paginación
  • Texto de búsqueda
  • Llamadas a la API cuando cambian estos valores

Pero falta algo importante: la URL no refleja nada de este estado.

  • Si cambias filtros, la URL no cambia
  • Si cambias de página, la URL no cambia
  • Si refrescas, pierdes todo y vuelves al estado inicial

La idea es que la URL sea un reflejo de la búsqueda actual, para poder:

  • Compartir el link con otra persona
  • Abrirlo en otra pestaña y que cargue igual
  • Refrescar la página sin perder filtros ni paginación

En el componente Search ya tenemos un efecto que se encarga de llamar a la API cuando cambian los filtros, el texto o la página actual:

useEffect(() => {
  // cada vez que cambian los filtros, el texto o la página
  // hacemos la llamada a la API
  fetchJobs({ filters, textToFilter, currentPage })
}, [filters, textToFilter, currentPage])

Esto está bien para los datos, pero:

  • No actualiza la URL
  • Si el usuario refresca, el estado se reinicia con valores por defecto

La solución será añadir otro efecto dedicado a sincronizar la URL.

Por qué sincronizar con la URL

Ventajas de sincronizar el estado con la URL:

Puedes copiar la URL y enviar exactamente la misma búsqueda a otra persona.

Por ejemplo:

/search?text=React&technology=javascript&type=remote&page=2

Refrescar sin perder el estado

El usuario puede hacer F5 y mantener filtros, texto y página actual.

Mejor experiencia de navegación

El historial del navegador tiene sentido. Botón de atrás y adelante respetan los filtros que se han usado.

Multi pestañas

Abrir la misma URL en otra pestaña muestra el mismo estado.

Estrategia general

Vamos a dividir la sincronización en dos partes:

  1. Escribir en la URL cuando cambie el estado: añadimos un useEffect que escucha cambios en filtros, texto y página, construye los query params y navega a la nueva URL
  2. Leer la URL cuando cargue la página: usamos inicialización perezosa en useState para inicializar el estado desde los parámetros de la URL

Paso 1: Efecto para reflejar el estado en la URL

En el componente Search añadimos un segundo efecto. Mismas dependencias que el anterior, pero con otro propósito: actualizar la URL.

useEffect(() => {
  const params = new URLSearchParams()

  if (textToFilter) {
    params.set('text', textToFilter)
  }

  if (filters.technology) {
    params.set('technology', filters.technology)
  }

  if (filters.location) {
    params.set('location', filters.location)
  }

  if (filters.experience) {
    params.set('experience', filters.experience)
  }

  if (currentPage > 1) {
    params.set('page', String(currentPage))
  }

  const paramsString = params.toString()
  const basePath = window.location.pathname

  const newUrl = paramsString ? `${basePath}?${paramsString}` : basePath

  navigateTo(newUrl)
}, [filters, textToFilter, currentPage, navigateTo])

Puntos clave:

  • Usamos URLSearchParams para construir los query params
  • Solo añadimos parámetros si tienen valor
  • La página solo se añade si es mayor que 1, porque la 1 es el valor por defecto
  • Usamos window.location.pathname en lugar de escribir a mano /search

No es buena idea hardcodear /search:

  • Si mañana cambiamos la ruta a /jobs, habría que buscar y reemplazar en el código
  • Con window.location.pathname usamos el path actual de forma automática

Usando el router casero (navigateTo)

En este proyecto tenemos un router propio con un hook useRouter que expone navigateTo:

const { navigateTo } = useRouter()

navigateTo(newUrl):

  • Actualiza la barra de direcciones
  • Lanza un evento para notificar que la URL ha cambiado
  • La app sigue funcionando como una Single Page Application

Por eso el efecto depende también de navigateTo y el linter nos obliga a incluirlo en el array de dependencias, aunque sepamos que no va a cambiar.

Paso 2: El problema al refrescar la página

Después de este paso:

  • Cambias filtros, página o texto → la URL se actualiza
  • Los resultados se actualizan

Pero si refrescas la página:

  • Los filtros se pierden
  • La página vuelve a 1
  • El texto de búsqueda se vacía

¿Por qué? Porque el estado inicial está definido con valores fijos:

const [filters, setFilters] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [textToFilter, setTextToFilter] = useState('')

Da igual lo que tenga la URL, siempre arrancamos desde cero.

Hay que hacer que el estado inicial dependa de la URL.

Paso 3: Inicializar el estado leyendo los parámetros de la URL

Inicializar textToFilter desde ?text=…

Usamos la forma de useState que recibe una función para inicializar el valor solo una vez:

const [textToFilter, setTextToFilter] = useState(() => {
  const params = new URLSearchParams(window.location.search)
  const text = params.get('text')

  return text ?? ''
})

Lo que hacemos:

  • window.location.search contiene la parte ?text=React&...
  • params.get('text') devuelve el valor o null
  • Si no hay valor, usamos '' como valor por defecto

Ahora:

  • Si entras en /search?text=React, textToFilter empieza siendo “React”
  • Las llamadas a la API ya arrancan con ese texto

Inicializar currentPage desde ?page=…

Queremos leer page de la URL, validar el valor y transformarlo en número:

const [currentPage, setCurrentPage] = useState(() => {
  const params = new URLSearchParams(window.location.search)
  const pageParam = params.get('page')

  if (!pageParam) return 1

  const page = Number(pageParam)

  if (Number.isNaN(page) || page < 1) {
    return 1
  }

  return page
})

Detalles importantes:

  • Todo lo que viene de la URL es texto
  • Hay que transformarlo con Number(...)
  • Validamos:
    • Si no hay parámetro, usamos 1
    • Si no es numérico o es menor que 1, usamos 1
    • Podríamos añadir más validaciones (limitar a un máximo, etc.)

Inicializar los filtros desde la URL

Hacemos lo mismo con el estado de filtros:

const [filters, setFilters] = useState(() => {
  const params = new URLSearchParams(window.location.search)

  const nextFilters = {}

  const technology = params.get('technology')
  if (technology) {
    nextFilters.technology = technology
  }

  const location = params.get('location')
  if (location) {
    nextFilters.location = location
  }

  const type = params.get('type')
  if (type) {
    nextFilters.type = type
  }

  const experience = params.get('experience')
  if (experience) {
    nextFilters.experience = experience
  }

  return nextFilters
})

Así:

  • Entrar en una URL como /search?technology=javascript&type=remote&experience=senior inicializa el estado con esos filtros
  • El efecto de llamada a la API usará esta información desde el primer render

Paso 4: Sincronizar el input de búsqueda con el valor inicial

Hay un detalle importante: la URL y los resultados son correctos, pero el input de búsqueda no muestra el texto hasta que escribes.

Solución:

  • El hook que maneja la página de búsqueda expone también el textToFilter
  • La página SearchPage se lo pasa al formulario como prop inicial
  • El formulario usa esa prop como valor inicial del input

Exponer textToFilter desde el hook

function useSearchPage() {
  const [textToFilter, setTextToFilter] = useState(/* inicialización desde la URL */)
  // ...más estado y lógica

  return {
    textToFilter,
    setTextToFilter,
    // otros valores
  }
}

Pasar el valor inicial al SearchForm

export function SearchPage() {
  const {
    textToFilter,
    filters,
    currentPage,
    // ...
  } = useSearchPage()

  return (
    <SearchForm
      initialTextToFilter={textToFilter}
      // resto de props
    />
  )
}

Usar initialTextToFilter dentro del formulario

En el SearchForm:

export function SearchForm({ initialTextToFilter, onTextFilter }) {
  const [text, setText] = useState(initialTextToFilter)

  const handleChange = (event) => {
    const value = event.target.value
    setText(value)
    onTextFilter(value)
  }

  return (
    <input type="text" value={text} onChange={handleChange} placeholder="Buscar por texto..." />
  )
}

Con esto:

  • El input arranca mostrando el texto que viene de la URL
  • Los cambios que hace el usuario se propagan al estado y a la URL

Comportamiento final

Al terminar esta clase, deberías poder:

Cambiar filtros, texto y página:

  • La URL se actualiza con los query params correctos

Refrescar la página:

  • Los resultados se mantienen
  • El input de texto muestra el valor correcto
  • Los select de filtros reflejan la selección
  • La paginación respeta el page de la URL

Compartir la URL:

  • Otra persona verá la misma búsqueda

Abrir la URL en otra pestaña:

  • El estado inicial coincidirá con lo que está codificado en la URL

Resumen y buenas prácticas

Ideas clave de esta clase:

  • Puedes tener varios useEffect con las mismas dependencias pero responsabilidades distintas: uno para datos, otro para sincronizar la URL
  • Usa URLSearchParams para construir y leer query params de forma más limpia
  • Evita hardcodear rutas como /search cuando puedas usar window.location.pathname
  • Inicializar estado desde la URL con funciones en useState evita recomputar en cada render
  • Valida siempre lo que viene de la URL: números que no son números, valores negativos, parámetros vacíos

Con esto, el buscador pasa de ser un componente aislado a ser una búsqueda navegable, compartible y robusta, integrada con el navegador.

¡Y ahora vamos a usar React Router para subir de nivel nuestra aplicación!