Guía paso a paso: Custom Hooks
Cómo construir custom hooks desde cero — comenzando simple, aumentando la complejidad intermedia.
Paso a paso: Cómo hacer un Custom Hook
- Identifica lógica repetida — Si dos o más componentes comparten la misma lógica stateful, extráela.
- Crea una función que comience con
use— React depende de esta convención de nombres para aplicar las Reglas de Hooks. - Mueve los hooks adentro —
useState,useEffect,useRef,useCallback, etc. van dentro de tu función. - Define el valor de retorno — Retorna lo que el componente consumidor necesita: un valor, un setter, un objeto, una tupla.
- Agrega tipos TypeScript — Tipifica los parámetros y el valor de retorno. Usa generics cuando el hook es agnóstico de valores.
- Memoiza referencias estables — Envuelve callbacks en
useCallbacky objetos retornados enuseMemopara evitar re-renders innecesarios en consumidores. - Maneja la limpieza — Si tu hook configura suscripciones, timers, o listeners, limpialos en funciones de retorno de
useEffect.
Hooks Simples
1. useToggle — Estado booleano con toggle
El hook más simple y útil. Envuelve useState(boolean) y retorna una función toggle estable.
import { useState, useCallback } from "react";
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const;
}Uso:
function DarkModeButton() {
const [isDark, toggleDark] = useToggle(false);
return <button onClick={toggleDark}>{isDark ? "Light" : "Dark"}</button>;
}Por qué funciona: useCallback con un array de dependencias vacío crea una referencia de función estable. La forma updater (v) => !v evita cierre obsoleto.
2. useDocumentTitle — Establece el título de la página reactivamente
import { useEffect } from "react";
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}Uso:
function ProfilePage({ user }: { user: { name: string } }) {
useDocumentTitle(`${user.name} — Profile`);
return <h1>{user.name}</h1>;
}Por qué funciona: El efecto se ejecuta solo cuando title cambia. No hay limpieza necesaria porque establecer document.title es idempotente.
3. useCounter — Estado numérico con increment/decrement/reset
import { useState, useCallback, useMemo } from "react";
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return useMemo(
() => ({ count, increment, decrement, reset }),
[count, increment, decrement, reset]
);
}Uso:
function CartQuantity() {
const { count, increment, decrement } = useCounter(1);
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}Por qué funciona: Retornar un objeto memoizado significa que los consumidores que desestructuran no causarán re-renders infantiles de una nueva referencia de objeto en cada render.
4. useIsMounted — Verifica si el componente aún está montado
Útil para proteger callbacks async que se resuelven después del desmontaje.
import { useRef, useEffect, useCallback } from "react";
function useIsMounted() {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
return useCallback(() => mounted.current, []);
}Uso:
function AsyncButton({ onClick }: { onClick: () => Promise<void> }) {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
await onClick();
if (isMounted()) setLoading(false); // safe guard
};
return <button onClick={handleClick} disabled={loading}>Go</button>;
}Hooks Intermedios
5. useLocalStorage — Persiste estado a localStorage con seguridad SSR
import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage full or blocked
}
}, [key, value]);
const remove = useCallback(() => {
setValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [value, setValue, remove] as const;
}Uso:
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}Detalles clave:
- El inicializador lazy en
useStateevita leer localStorage en cada render. - El guardia
typeof window === "undefined"lo hace seguro para SSR. - El genérico
<T>te permite almacenar strings, números, objetos, arrays.
6. useDebounce — Retrasa actualizaciones de valor hasta que la entrada se estabilice
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}Uso:
function SearchInput() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Detalles clave:
- Cada pulsación de tecla borra el timer anterior a través de limpieza.
- El valor debounced solo se actualiza después de
delayms de inactividad.
7. useClickOutside — Detecta clics fuera de un elemento ref
import { useEffect, useRef } from "react";
function useClickOutside<T extends HTMLElement>(
handler: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
const listener = (e: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler();
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [handler]);
return ref;
}Uso:
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen(true)}>Menu</button>
{open && <ul><li>Option A</li><li>Option B</li></ul>}
</div>
);
}Detalles clave:
- Usa
mousedown(noclick) para que el dropdown se cierre antes de que el click se complete. - Se manejan eventos de mouse y touch para dispositivos móviles.
- El
handlerdebe envolverse enuseCallbacken el consumidor para evitar readjuntar listeners en cada render.
8. useMediaQuery — Coincidencia reactiva de media query CSS
import { useState, useEffect } from "react";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", onChange);
setMatches(mql.matches); // sync in case it changed before effect ran
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}Uso:
function ResponsiveLayout() {
const isMobile = useMediaQuery("(max-width: 768px)");
return isMobile ? <MobileNav /> : <DesktopNav />;
}9. useFetch — Data fetching con loading/error states y abort
import { useState, useEffect, useRef } from "react";
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}Uso:
function UserProfile({ id }: { id: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;
return <h1>{data?.name}</h1>;
}Detalles clave:
- Las solicitudes anteriores se abortan cuando
urlcambia — evita condiciones de carrera. AbortErrorse captura silenciosamente para que no aparezca como un estado de error.- Para producción, considera librerías como TanStack Query en lugar de esto; esto es para aprender el patrón.
10. useEventListener — Enlace de evento type-safe y declarativo
import { useEffect, useRef } from "react";
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
element?: HTMLElement | null
) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const target = element ?? window;
const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K]);
target.addEventListener(event, listener);
return () => target.removeEventListener(event, listener);
}, [event, element]);
}Uso:
function EscapeHandler({ onEscape }: { onEscape: () => void }) {
useEventListener("keydown", (e) => {
if (e.key === "Escape") onEscape();
});
return null;
}Detalles clave:
- Patrón
handlerRef: el listener es estable (nunca se readjunta), pero siempre llama al handler más reciente. Esto evita cierre obsoleto sin requerirhandleren el array de dependencias. - El genérico
K extends keyof WindowEventMapproporciona autocompletado completo en nombres de eventos y objetos de evento tipados.
Gotchas (Trampas)
-
El prefijo
usees requerido. El linter de React y las Reglas de Hooks dependen de él.toggleState()no obtendrá advertencias de lint si rompes las reglas de hooks adentro. -
No llames hooks condicionalmente dentro de tu custom hook. Las mismas reglas se aplican — los hooks deben llamarse en el mismo orden en cada render.
-
Cierre obsoleto en efectos. Si tu hook captura una prop callback en
useEffect, usa una ref (useRef+useEffectpara mantenerla fresca) en lugar de agregar el callback al array de dependencias. -
Retornar nuevos objetos/arrays en cada render. Si tu hook retorna
{ value, toggle }sinuseMemo, cada consumidor se re-renderiza cuando el padre del hook se re-renderiza — incluso si nada cambió. -
Limpieza faltante. Olvidar limpiar timers, remover listeners, o abortar fetches causa memory leaks y actualizaciones de estado en componentes desmontados.
-
Desincronización de hidratación en SSR. Los hooks que leen
window,document,localStorage, omatchMedianecesitan guardias (typeof window === "undefined") y a veces una estrategia de renderizado de dos pasadas. -
Abuso de custom hooks. No todo necesita ser un hook. Si la lógica es pura (sin hooks adentro), hazla una función plana. Si solo se usa en un componente e improbable que se reutilice, mantenla inline.
-
Inferencia de tipo genérico. Cuando uses genéricos como
useLocalStorage<T>, asegúrate de que el valor inicial coincida con el genérico o TypeScript inferiráunknown. Proporciona argumentos de tipo explícitos cuando el valor inicial es ambiguo (p. ej.,null). -
Dependencias de efectos con objetos/arrays. Pasar un objeto o array como dependencia a
useEffectdentro de un custom hook causa que se vuelva a ejecutar en cada render (nueva referencia). Desestructura a primitivos o usa una utilidad de comparación profunda. -
Testing de custom hooks. Usa
renderHookde@testing-library/react— no puedes llamar hooks fuera de un componente. Envuelve enact()cuando dispares actualizaciones de estado.