Hook useRef: referencias y valores que persisten entre renders

En esta clase continuamos justo donde lo dejamos con el buscador y el debounce. Ya tenemos búsqueda, paginación y filtros funcionando, y además aplicamos debounce para no llamar a la API en cada tecla. Pero tenemos un pequeño apaño que hay que corregir: estamos usando una variable global para guardar el timeoutId del debounce. Ha llegado el momento de usar useRef para hacerlo bien.

Contexto: cómo estaba el proyecto

Recordatorio rápido de lo que ya teníamos:

  • Home con un buscador de ofertas
  • Navegación a la página de resultados
  • Paginación funcionando
  • Filtros por tecnología, ubicación, experiencia
  • Un buscador por texto con debounce para evitar hacer una petición por cada letra

En el código del custom hook del buscador, para implementar el debounce, hicimos algo como esto:

let timeoutId: number | null = null // 👈 variable fuera del hook

export function useSearchForm(/* props */) {
  const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const text = event.target.value

    // cancelar timeout anterior
    if (timeoutId) {
      clearTimeout(timeoutId)
    }

    // crear nuevo timeout
    timeoutId = window.setTimeout(() => {
      onTextFilter(text)
    }, 500)
  }

  // ...
}

Esto funcionaba, pero era un apaño temporal. Vamos a ver por qué.

Por qué una variable global es una mala idea

Problema 1 - Se comparte entre instancias

Si ese custom hook o componente se renderizase varias veces en la misma página:

  • Todas las instancias compartirían el mismo timeoutId
  • Un buscador podría cancelar el timeout de otro
  • El comportamiento sería impredecible

Aunque en este proyecto solo tenemos un buscador, no deja de ser mala práctica.

Problema 2 - Dentro del hook tampoco vale

La alternativa ingenua sería:

export function useSearchForm(/* props */) {
  let timeoutId: number | null = null // ❌ variable local

  const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    clearTimeout(timeoutId)

    timeoutId = window.setTimeout(() => {
      onTextFilter(event.target.value)
    }, 500)
  }

  // ...
}

¿Por qué esto no funciona?

  • React vuelve a ejecutar el hook en cada render
  • Cada vez que se renderiza, timeoutId se inicializa otra vez a null
  • Pierdes la referencia al timeout anterior
  • clearTimeout(timeoutId) acaba siendo casi siempre clearTimeout(null), que no limpia nada

Necesitamos algo que:

  • Guarde un valor entre renders
  • No se reinicie al renderizar
  • No dispare un nuevo render cada vez que lo actualizamos

Eso es exactamente lo que hace useRef.

Qué es useRef (y en qué se diferencia de useState)

useRef es un hook que te permite:

  • Guardar un valor que persiste entre renderizados
  • Leer y escribir ese valor
  • Sin provocar re-renders cuando cambia

Comparación rápida con useState:

useState

  • Guarda un valor
  • Cuando lo actualizas, React vuelve a renderizar el componente

useRef

  • Guarda un valor en la propiedad .current
  • Cuando cambias .current, React no re-renderiza

Piensa en useRef como:

Una caja donde guardas un valor que quieres recordar entre renders, pero que no afecta directamente a la UI.

Sintaxis básica de useRef

Primero, lo importamos:

import { useRef } from 'react'

Luego lo usamos así:

const myRef = useRef('hola')

console.log(myRef)

Si miras el console log, verás algo como:

{
  current: 'hola'
}

Es decir:

  • useRef(valorInicial) devuelve un objeto
  • Ese objeto tiene una propiedad .current
  • Dentro de .current vive el valor

Para leer o escribir el valor:

console.log(myRef.current) // 👉 'hola'

myRef.current = 'adiós'

console.log(myRef.current) // 👉 'adiós'

No hay setters, no hay setAlgo. Es mutación directa sobre .current.

Ejemplo práctico: contar cuántas veces se usa un input

Para entenderlo mejor, en el vídeo se hace un ejemplo: contar cuántas veces se ha escrito en el input del buscador.

Crear la referencia

const counterRef = useRef(0)

Usarla en handleTextChange

const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  const text = event.target.value

  // actualizar estado controlado del input
  setSearchText(text)

  // incrementar contador
  counterRef.current += 1

  console.log('Usado el input tantas veces:', counterRef.current)
}

¿Qué pasa aquí?

Cada vez que el usuario escribe:

  • Se renderiza el componente (porque cambiamos el estado searchText)
  • Pero counterRef.current no se resetea
  • El valor se mantiene entre renders
  • Cuando lo modificamos, no provocamos re-render por ello

Este ejemplo sirve para ver dos ideas clave:

  1. El valor persiste entre renderizados
  2. Cambiar .current no dispara un nuevo render

Usando useRef para arreglar el debounce

Volvemos al problema original: el timeoutId del debounce.

Antes: variable global

let timeoutId: number | null = null

export function useSearchForm(/* props */) {
  const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const text = event.target.value

    setSearchText(text)

    if (timeoutId) {
      clearTimeout(timeoutId)
    }

    timeoutId = window.setTimeout(() => {
      onTextFilter(text)
    }, 500)
  }

  // ...
}

Después: ref dentro del hook

Vamos a mover ese valor a una referencia:

import { useRef, useState } from 'react'

export function useSearchForm({ onTextFilter, /* resto de props */ }) {
  const [searchText, setSearchText] = useState('')
  const timeoutId = useRef<number | null>(null) // 👈 ref para el timeout

  const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const text = event.target.value

    // 1. Actualizamos el estado del input
    setSearchText(text)

    // 2. Si ya hay un timeout programado, lo cancelamos
    if (timeoutId.current) {
      clearTimeout(timeoutId.current)
    }

    // 3. Programamos un nuevo timeout y guardamos el id en la ref
    timeoutId.current = window.setTimeout(() => {
      onTextFilter(text)
    }, 500)
  }

  // ...

  return {
    searchText,
    handleTextChange,
    // handleSubmit, etc...
  }
}

Qué hemos ganado

timeoutId ahora:

  • Vive dentro del hook
  • Es independiente para cada instancia del hook
  • Persiste entre renders
  • Ya no necesitamos variables globales
  • El código es más React friendly y escalable

Segundo uso clave: acceder al DOM con useRef

useRef no solo sirve para valores “normales”. También es la forma recomendada en React de acceder a elementos del DOM sin usar document.getElementById, querySelector, etc.

El código “old school” (mala práctica)

En el vídeo se enseña un caso real: un botón para limpiar el input de búsqueda.

Versión inicial:

const handleClearInput = (event: React.MouseEvent<HTMLButtonElement>) => {
  event.preventDefault()

  const input = document.getElementById('search-input') as HTMLInputElement | null

  if (!input) return

  input.value = ''
  onTextFilter('') // para resetear los resultados
}

Problemas:

  • Rompe el modelo de React, que quiere ser quien controle el DOM
  • A largo plazo es difícil de mantener y razonar
  • Es una mala práctica en componentes React modernos

Versión correcta con useRef

Paso 1 - Creamos una ref para el input:

const inputRef = (useRef < HTMLInputElement) | (null > null)

Paso 2 - Se la pasamos al input:

<input
  ref={inputRef}
  type="text"
  name={idText}
  value={searchText}
  onChange={handleTextChange}
  placeholder="Buscar empleos..."
/>

Paso 3 - Usamos la ref en el handler:

const handleClearInput = (event: React.MouseEvent<HTMLButtonElement>) => {
  event.preventDefault()

  if (!inputRef.current) return

  // limpiar el input en la UI controlada
  inputRef.current.value = ''
  onTextFilter('') // o setSearchText('') si lo controlas por estado
}

Y la ref la puedes usar para hacer cosas como:

  • inputRef.current?.focus() después de limpiar
  • Mover el foco a ese input cuando entras en la página
  • Hacer scroll hacia ese elemento

Por qué esto sí es una buena práctica

  • No dependes del id ni del DOM global
  • La referencia vive dentro del componente
  • React sabe qué elemento está asociado a esa ref
  • Sigues trabajando dentro del modelo declarativo de React

Resumen de casos de uso de useRef que has visto

En esta clase has visto dos usos muy importantes de useRef:

Guardar valores mutables que persisten entre renders

  • Ejemplo: timeoutId.current para el debounce
  • Ejemplo: counterRef.current para contar cuántas veces se ha usado el input
  • Cambiar el valor no dispara un re-render

Guardar referencias a elementos del DOM

  • Ejemplo: inputRef.current para limpiar o enfocar el input
  • Evitamos document.getElementById y similares
  • Usamos la forma “oficial” de React para acceder al DOM

Errores comunes con useRef que debes evitar

Olvidar .current

Es muy típico intentar hacer timeoutId = setTimeout(...). Con useRef siempre es timeoutId.current = ....

Usar useRef como sustituto de todo el estado

  • Si el valor tiene que pintar algo en pantalla cuando cambia, entonces necesitas useState.
  • Si es solo un valor de trabajo interno (timeout, contador, referencia, etc.), useRef es perfecto.

Pensar que useRef dispara renders

Cambiar myRef.current no actualizará la UI por sí solo. Si quieres que algo se vea en pantalla, tiene que pasar por useState o venir de props.

Resumen final de la clase

En esta clase has aprendido:

  • Por qué una variable global para el timeoutId del debounce es una mala práctica
  • Por qué una variable local dentro del hook tampoco sirve (se reinicia en cada render)
  • Qué es useRef y en qué se diferencia de useState
  • Cómo funciona la estructura { current: valor }
  • Cómo contar cuántas veces se ha usado un input con una referencia
  • Cómo migrar el debounce para usar useRef en lugar de una variable global
  • Cómo acceder a elementos del DOM con useRef en lugar de document.getElementById
  • Cuándo usar useRef y cuándo seguir usando useState

Con esto, tu buscador:

  • Sigue teniendo debounce
  • Ya no depende de variables globales
  • Utiliza el patrón correcto de React para manejar valores que persisten entre renders y referencias al DOM