Guía paso a paso: Custom Hooks
Cómo construir custom hooks desde cero - comenzando simple, aumentando la complejidad intermedia.
Busca en todas las páginas de la documentación
Cómo construir custom hooks desde cero - comenzando simple, aumentando la complejidad intermedia.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
use - React depende de esta convención de nombres para aplicar las Reglas de Hooks.useState, useEffect, useRef, useCallback, etc. van dentro de tu función.useCallback y objetos retornados en useMemo para evitar re-renders innecesarios en consumidores.useEffect.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.
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.
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.
Ú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>;
}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:
useState evita leer localStorage en cada render.typeof window === "undefined" lo hace seguro para SSR.<T> te permite almacenar strings, números, objetos, arrays.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:
delay ms de inactividad.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:
mousedown (no click) para que el dropdown se cierre antes de que el click se complete.handler debe envolverse en useCallback en el consumidor para evitar readjuntar listeners en cada render.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 />;
}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:
url cambia - evita condiciones de carrera.AbortError se captura silenciosamente para que no aparezca como un estado de error.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:
handlerRef: el listener es estable (nunca se readjunta), pero siempre llama al handler más reciente. Esto evita cierre obsoleto sin requerir handler en el array de dependencias.K extends keyof WindowEventMap proporciona autocompletado completo en nombres de eventos y objetos de evento tipados.El prefijo use es 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 + useEffect para 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 } sin useMemo, 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, o matchMedia necesitan 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 useEffect dentro 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 renderHook de @testing-library/react - no puedes llamar hooks fuera de un componente. Envuelve en act() cuando dispares actualizaciones de estado.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥