Tema
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:
- Su nombre empieza con
use(useFetch,useLocalStorage). - Puede usar otros hooks (
useState,useEffect, otros custom hooks). - 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 escribiruseMediaQuery
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-tso@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
- Crea
useOnline()que devuelvatrue/falsesegún la conexión (eventosonline/offline). - Crea
useFormulario(valoresIniciales, validar)que devuelva{ valores, errores, manejarCambio, reiniciar }, dondevalidares 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
usey 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.