Skip to content

Custom hooks ​

Cuando la misma lógica con estado y efectos se repite en varios componentes (pedir datos, guardar en localStorage, detectar el tamaño de pantalla), puedes extraerla a un custom hook. Es la forma de React de reutilizar lógica, y una señal de código profesional.

Concepto ​

Un custom hook es una función que:

  1. Su nombre empieza con use (useFetch, useLocalStorage).
  2. Puede usar otros hooks (useState, useEffect, otros custom hooks).
  3. Devuelve lo que quieras: un valor, un arreglo, un objeto, funciones.

Analogía: si los componentes son piezas de LEGO visuales, los custom hooks son los motores que puedes instalar en cualquier pieza. El motor no sabe qué forma tiene la pieza; sólo aporta el movimiento.

Importante

Los custom hooks reutilizan lógica, no estado. Si dos componentes usan useContador(), cada uno tiene su propio contador independiente. Para compartir el mismo estado usarás Context o Zustand.

Tu primer custom hook ​

Antes, la lógica vive en el componente:

jsx
function Contador() {
  const [valor, setValor] = useState(0)
  const incrementar = () => setValor((v) => v + 1)
  const decrementar = () => setValor((v) => v - 1)
  const reiniciar = () => setValor(0)
  // ...
}

Después, extraída a un hook:

jsx
import { useState } from 'react'

export function useContador(inicial = 0, { min = -Infinity, max = Infinity } = {}) {
  const [valor, setValor] = useState(inicial)

  const incrementar = () => setValor((v) => Math.min(v + 1, max))
  const decrementar = () => setValor((v) => Math.max(v - 1, min))
  const reiniciar = () => setValor(inicial)

  return { valor, incrementar, decrementar, reiniciar }
}
jsx
import { useContador } from '../hooks/useContador'

export default function SelectorCantidad({ stock }) {
  const { valor, incrementar, decrementar } = useContador(1, { min: 1, max: stock })

  return (
    <div>
      <button onClick={decrementar} disabled={valor === 1}>−</button>
      <span style={{ margin: '0 12px' }}>{valor}</span>
      <button onClick={incrementar} disabled={valor === stock}>+</button>
      <small> (máx. {stock})</small>
    </div>
  )
}

Hooks útiles para tus proyectos ​

useToggle ​

jsx
// src/hooks/useToggle.js
import { useCallback, useState } from 'react'

export function useToggle(inicial = false) {
  const [valor, setValor] = useState(inicial)
  const toggle = useCallback(() => setValor((v) => !v), [])
  return [valor, toggle, setValor]
}

// Uso
// const [menuAbierto, toggleMenu] = useToggle()
// <button onClick={toggleMenu}>☰</button>

useLocalStorage ​

Igual que useState, pero el valor sobrevive al recargar la página:

jsx
// src/hooks/useLocalStorage.js
import { useEffect, useState } from 'react'

export function useLocalStorage(clave, valorInicial) {
  const [valor, setValor] = useState(() => {
    try {
      const guardado = localStorage.getItem(clave)
      return guardado !== null ? JSON.parse(guardado) : valorInicial
    } catch {
      return valorInicial
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(clave, JSON.stringify(valor))
    } catch {
      // modo incógnito o almacenamiento lleno: ignoramos
    }
  }, [clave, valor])

  return [valor, setValor]
}

// Uso: misma API que useState
// const [tema, setTema] = useLocalStorage('tema', 'claro')

useDebounce ​

jsx
// src/hooks/useDebounce.js
import { useEffect, useState } from 'react'

export function useDebounce(valor, retraso = 400) {
  const [debounced, setDebounced] = useState(valor)

  useEffect(() => {
    const id = setTimeout(() => setDebounced(valor), retraso)
    return () => clearTimeout(id)
  }, [valor, retraso])

  return debounced
}

// Uso
// const [texto, setTexto] = useState('')
// const termino = useDebounce(texto, 500)  // cambia 500 ms después de dejar de escribir

useMediaQuery ​

jsx
// src/hooks/useMediaQuery.js
import { useEffect, useState } from 'react'

export function useMediaQuery(consulta) {
  const [coincide, setCoincide] = useState(() => window.matchMedia(consulta).matches)

  useEffect(() => {
    const mql = window.matchMedia(consulta)
    const manejar = (e) => setCoincide(e.matches)
    setCoincide(mql.matches)
    mql.addEventListener('change', manejar)
    return () => mql.removeEventListener('change', manejar)
  }, [consulta])

  return coincide
}

// Uso
// const esMovil = useMediaQuery('(max-width: 767px)')
// const prefiereOscuro = useMediaQuery('(prefers-color-scheme: dark)')

useFetch ​

La lógica de la lección anterior, reutilizable:

jsx
// src/hooks/useFetch.js
import { useCallback, useEffect, useState } from 'react'

export function useFetch(url, opciones) {
  const [datos, setDatos] = useState(null)
  const [error, setError] = useState(null)
  const [cargando, setCargando] = useState(Boolean(url))
  const [version, setVersion] = useState(0)

  const recargar = useCallback(() => setVersion((v) => v + 1), [])

  useEffect(() => {
    if (!url) return // permite "pausar" pasando null
    const controlador = new AbortController()
    setCargando(true)
    setError(null)

    fetch(url, { ...opciones, signal: controlador.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Error ${res.status}`)
        return res.json()
      })
      .then(setDatos)
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err)
      })
      .finally(() => {
        if (!controlador.signal.aborted) setCargando(false)
      })

    return () => controlador.abort()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url, version])

  return { datos, error, cargando, recargar }
}

Ahora un componente que pide datos queda así de simple:

jsx
import { useState } from 'react'
import { useFetch } from './hooks/useFetch'
import { useDebounce } from './hooks/useDebounce'

export default function BuscadorRecetas() {
  const [texto, setTexto] = useState('')
  const q = useDebounce(texto.trim(), 400)
  const url = q ? `https://dummyjson.com/recipes/search?q=${encodeURIComponent(q)}` : null
  const { datos, error, cargando, recargar } = useFetch(url)

  return (
    <div style={{ maxWidth: 600, margin: '2rem auto' }}>
      <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Busca: pasta, pizza, tacos…" />
      {cargando && <p>Buscando…</p>}
      {error && <p>Error: {error.message} <button onClick={recargar}>Reintentar</button></p>}
      {datos && (
        <ul>
          {datos.recipes.map((r) => (
            <li key={r.id}>{r.name} · ⏱ {r.prepTimeMinutes + r.cookTimeMinutes} min</li>
          ))}
        </ul>
      )}
    </div>
  )
}

useClickFuera ​

Para cerrar menús o dropdowns al hacer clic fuera de ellos (usa useRef, que verás a detalle en la siguiente lección):

jsx
// src/hooks/useClickFuera.js
import { useEffect, useRef } from 'react'

export function useClickFuera(alHacerClicFuera) {
  const ref = useRef(null)

  useEffect(() => {
    function manejar(e) {
      if (ref.current && !ref.current.contains(e.target)) alHacerClicFuera()
    }
    document.addEventListener('pointerdown', manejar)
    return () => document.removeEventListener('pointerdown', manejar)
  }, [alHacerClicFuera])

  return ref
}

// Uso
// const ref = useClickFuera(() => setAbierto(false))
// <div ref={ref}>...menú...</div>

Componer hooks ​

Los hooks pueden usar otros hooks. Por ejemplo, un tema claro/oscuro que recuerda la preferencia y respeta la del sistema:

jsx
// src/hooks/useTema.js
import { useEffect } from 'react'
import { useLocalStorage } from './useLocalStorage'
import { useMediaQuery } from './useMediaQuery'

export function useTema() {
  const sistemaOscuro = useMediaQuery('(prefers-color-scheme: dark)')
  const [preferencia, setPreferencia] = useLocalStorage('tema', 'sistema') // 'claro' | 'oscuro' | 'sistema'

  const tema = preferencia === 'sistema' ? (sistemaOscuro ? 'oscuro' : 'claro') : preferencia

  useEffect(() => {
    document.documentElement.dataset.tema = tema // <html data-tema="oscuro">
  }, [tema])

  return { tema, preferencia, setPreferencia }
}
css
/* index.css */
:root { --fondo: #ffffff; --texto: #111111; }
[data-tema='oscuro'] { --fondo: #0f172a; --texto: #e2e8f0; }
body { background: var(--fondo); color: var(--texto); }

Buena práctica

  • Organiza tus hooks en src/hooks/ (o dentro de cada feature, ver Patrones).
  • Antes de escribir uno, revisa librerías probadas como usehooks-ts o @uidotdev/usehooks.
  • Un buen custom hook tiene un nombre que describe qué hace, no cómo.

Error común

Crear una función que usa hooks pero no empieza con use (por ejemplo obtenerDatos() con un useState dentro). React y ESLint no podrán verificar las reglas de los hooks y tendrás errores difíciles de rastrear.

Ejercicios ​

  1. Crea useOnline() que devuelva true/false según la conexión (eventos online/offline).
  2. Crea useFormulario(valoresIniciales, validar) que devuelva { valores, errores, manejarCambio, reiniciar }, donde validar es una función que recibe los valores y devuelve un objeto de errores.
Ver solución
jsx
// 1. src/hooks/useOnline.js
import { useEffect, useState } from 'react'

export function useOnline() {
  const [online, setOnline] = useState(() => navigator.onLine)
  useEffect(() => {
    const on = () => setOnline(true)
    const off = () => setOnline(false)
    window.addEventListener('online', on)
    window.addEventListener('offline', off)
    return () => {
      window.removeEventListener('online', on)
      window.removeEventListener('offline', off)
    }
  }, [])
  return online
}
jsx
// 2. src/hooks/useFormulario.js
import { useState } from 'react'

export function useFormulario(iniciales, validar = () => ({})) {
  const [valores, setValores] = useState(iniciales)
  const errores = validar(valores)

  function manejarCambio(e) {
    const { name, value, type, checked } = e.target
    setValores((v) => ({ ...v, [name]: type === 'checkbox' ? checked : value }))
  }

  const reiniciar = () => setValores(iniciales)
  const esValido = Object.keys(errores).length === 0

  return { valores, errores, esValido, manejarCambio, reiniciar }
}

// Uso
// const { valores, errores, esValido, manejarCambio } = useFormulario(
//   { email: '' },
//   (v) => (v.email.includes('@') ? {} : { email: 'Email inválido' })
// )

Resumen ​

  • Un custom hook es una función que empieza con use y usa otros hooks.
  • Reutiliza lógica (no estado compartido): cada componente que lo usa tiene su propio estado.
  • Hooks muy útiles: useLocalStorage, useDebounce, useMediaQuery, useFetch, useClickFuera.
  • Los hooks se pueden componer para crear comportamientos más complejos.

Academia Web — aprende, copia y construye.