Hooks Personalizados
Extrae lógica con estado reutilizable en funciones que comienzan con use — el mecanismo principal de reutilización de código de React.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
// Pattern: a custom hook is just a function that calls other hooks
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const;
}
// Usage
const [isOpen, toggleOpen] = useToggle(false);Cuándo usarlo: Cuando encuentras que estás duplicando la misma combinación de useState, useEffect, useRef u otros hooks en varios componentes.
Ejemplo Funcional
"use client";
import { useCallback, useEffect, useState } from "react";
// Custom hook: local storage state
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 unavailable
}
}, [key, value]);
const remove = useCallback(() => {
setValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [value, setValue, remove] as const;
}
// Component using the custom hook
export function Preferences() {
const [name, setName, clearName] = useLocalStorage("user-name", "");
const [darkMode, setDarkMode] = useLocalStorage("dark-mode", false);
return (
<div className={`space-y-4 p-4 rounded ${darkMode ? "bg-gray-900 text-white" : "bg-white"}`}>
<div>
<label className="block text-sm font-medium mb-1">Nombre</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="border rounded px-3 py-2 text-black"
placeholder="Ingresa tu nombre"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={darkMode}
onChange={(e) => setDarkMode(e.target.checked)}
/>
<span className="text-sm">Modo oscuro</span>
</label>
<div className="flex gap-2">
<button onClick={clearName} className="text-sm text-blue-500 underline">
Limpiar nombre
</button>
</div>
{name && <p className="text-sm">¡Hola, {name}!</p>}
</div>
);
}Lo que esto demuestra:
- Un hook personalizado completamente tipificado (
useLocalStorage) que componeuseState,useEffectyuseCallback - Seguridad SSR con la verificación
typeof window - El hook devuelve una tupla con valor, setter y una función de eliminación
as constestrecha el tipo de retorno deArraya una tupla específica- Dos usos independientes en el mismo componente con diferentes claves y tipos
Profundización
Cómo Funciona
- Un hook personalizado es cualquier función cuyo nombre comienza con
usey llama otros hooks - React rastrea los hooks por orden de llamada dentro de cada componente — los hooks internos de tu hook personalizado forman parte de la lista de hooks del componente que los llama
- Cada instancia de componente que llama tu hook personalizado obtiene su propio estado independiente
- Los hooks personalizados pueden llamar otros hooks personalizados, permitiendo composición profunda
- El prefijo
usees obligatorio — le señala a React (y a las herramientas de linting) que la función sigue las reglas de hooks
Parámetros y Valores de Retorno
| Patrón | Convención | Ejemplo |
|---|---|---|
| Valor único | Devuelve el valor directamente | useOnlineStatus() → boolean |
| Valor + setter | Devuelve una tupla [value, setter] | useToggle() → [boolean, () => void] |
| Múltiples valores | Devuelve un objeto | useFetch() → { data, error, loading } |
| Solo acciones | Devuelve un objeto de funciones | useClipboard() → { copy, paste } |
Variaciones
useDebounce — deduplica un valor que cambia rápidamente:
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const debouncedQuery = useDebounce(query, 300);useFetch — obtención de datos con estados de carga y error:
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((json) => { setData(json); setError(null); })
.catch((err) => { if (err.name !== "AbortError") setError(err); })
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}useMediaQuery — puntos de quiebre responsivos:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
function handler(e: MediaQueryListEvent) {
setMatches(e.matches);
}
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery("(max-width: 768px)");useClickOutside — detecta clics fuera de un ref:
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
handler();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, handler]);
}usePrevious — rastrea el valor anterior:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}Notas de TypeScript
// Use generics for reusable hooks
function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] { ... }
// Use `as const` for tuple returns so destructuring types are correct
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle] as const;
// Return type: readonly [boolean, () => void]
// Without `as const`: (boolean | (() => void))[]
}
// Use overloads for hooks with multiple call signatures
function useControllable<T>(value: T): [T, (v: T) => void];
function useControllable<T>(value: undefined, defaultValue: T): [T, (v: T) => void];
function useControllable<T>(value: T | undefined, defaultValue?: T) {
const [internal, setInternal] = useState(defaultValue ?? value!);
if (value !== undefined) return [value, () => {}] as const;
return [internal, setInternal] as const;
}Errores Comunes
-
No comenzar con
use— Si tu hook se llamagetToggleen lugar deuseToggle, el linter no aplicará las reglas de hooks, generando bugs sutiles. Solución: Siempre prefija los hooks personalizados conuse. -
Llamar hooks condicionalmente dentro de hooks personalizados — Las reglas de hooks se aplican también dentro de hooks personalizados. Solución: Nunca pongas
useStateouseEffectdentro de un bloqueifo después de un retorno temprano. -
Devolver referencias inestables — Devolver un nuevo objeto
{ value, toggle }en cada renderizado causa que las dependencias deuseEffectde los consumidores cambien cada vez. Solución: UsauseMemopara estabilizar objetos, o devuelve una tupla. -
Sobre-abstraer — Crear un hook personalizado para lógica usada solo en un componente agrega indirección sin beneficio. Solución: Extrae en un hook personalizado solo cuando la lógica se usa en 2+ componentes o cuando mejora la legibilidad de un componente complejo.
-
Limpieza faltante — Olvidar limpiar suscripciones, temporizadores o listeners de eventos en tu hook personalizado causa fugas de memoria. Solución: Siempre devuelve una función de limpieza desde
useEffectdentro de tu hook. -
Cierre obsoleto en callbacks devueltos — Las callbacks devueltas desde hooks personalizados pueden quedar atrapadas en estado obsoleto si no se envuelven en
useCallbackcon dependencias apropiadas. Solución: UsauseCallbackpara cualquier función que devuelvas, o usa el patrón de actualizador.
Alternativas
| Alternativa | Úsalo Cuando | No lo uses cuando |
|---|---|---|
| Render props | Necesitas compartir lógica de renderizado de UI, no solo estado | Solo necesitas compartir lógica con estado |
| Componentes de orden superior (HOC) | La base de código heredada requiere envolver componentes | Empezando código nuevo — los hooks son más simples |
| Funciones de utilidad | La lógica es pura (sin hooks, sin estado, sin efectos) | La lógica involucra estado de React o ciclo de vida |
| Context + Provider | El estado compartido necesita ser accesible por todo el subárbol | Cada consumidor necesita estado independiente |
| Hooks de terceros (react-use, usehooks-ts) | Una implementación bien probada ya existe | Tu caso de uso es único para tu dominio |
Cuándo extraer un hook personalizado: Si tienes 2+ componentes con la misma combinación de useState + useEffect, o si la lógica de hooks de un componente excede ~15 líneas y tiene una responsabilidad clara, extrae la lógica.
Ejemplo del Mundo Real
De una aplicación SaaS de producción Next.js 15 / React 19 (SystemsArchitect.io).
// Production example: Auth hook with session + real-time subscription
// File: src/hooks/use-auth.ts
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { type User } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase/client'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const getSession = async () => {
try {
const { data: { session } } = await supabase.auth.getSession()
setUser(session?.user ?? null)
} catch (error) {
console.error('Error getting session:', error)
} finally {
setLoading(false)
}
}
getSession()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setUser(session?.user ?? null)
setLoading(false)
}
)
return () => { subscription.unsubscribe() }
}, [])
const signOut = useCallback(async () => {
try {
await supabase.auth.signOut()
setUser(null)
} catch (error) {
console.error('Error signing out:', error)
}
}, [])
return useMemo(() => ({
user,
loading,
signOut,
isAuthenticated: user !== null,
userId: user?.id || null,
}), [user, loading, signOut])
}Lo que esto demuestra en producción:
- Tres preocupaciones en un hook: obtención de sesión inicial, cambios de auth en tiempo real y cierre de sesión
- La limpieza
subscription.unsubscribe()previene fugas de memoria cuando el componente se desmonta useCallbackensignOutcrea una referencia estable para que los consumidores que la usan en arrays de dependencias no vuelvan a ejecutar efectosuseMemoen el objeto de retorno previene re-renderizados innecesarios. Sin él, se crea una nueva referencia de objeto en cada renderizado incluso si los valores son igualesisAuthenticated: user !== nulles un valor derivado calculado del estado, no almacenado por separado- Cada componente que llama
useAuth()obtiene su propio estado pero todos se sincronizan a través deonAuthStateChange
FAQs
¿Qué hace que una función sea un hook personalizado vs una función de utilidad regular?
- Un hook personalizado comienza con
usey llama otros hooks (useState,useEffect, etc.) internamente. - Una función de utilidad regular es pura -- sin hooks, sin estado, sin efectos.
- Si tu función no llama ningún hook, es una función de utilidad y no debe ser prefijada con
use.
¿Varios componentes que llaman el mismo hook personalizado comparten estado?
- No. Cada instancia de componente que llama un hook personalizado obtiene su propio estado independiente.
- React rastrea los hooks por orden de llamada dentro de cada componente.
- El estado compartido requiere context, un almacén global, o elevación de estado.
¿Por qué debo devolver una tupla con as const en lugar de un objeto?
// Without `as const`: (boolean | (() => void))[]
// With `as const`: readonly [boolean, () => void]
return [value, toggle] as const;as constestrecha el tipo de retorno a una tupla específica, habilitando tipos de desestructuración correctos.- Sin él, TypeScript infiere un array de unión donde ambos elementos podrían ser cualquier tipo.
Error Común: ¿Qué sucede si el nombre de mi hook personalizado no comienza con "use"?
- El linter de React no aplicará las reglas de hooks para la función.
- Los hooks llamados dentro de ella pueden ser llamados condicionalmente o en bucles sin advertencia.
- Esto genera bugs sutiles. Siempre prefija los hooks personalizados con
use.
¿Cuándo debo extraer lógica en un hook personalizado vs mantenerla en el componente?
- Extrae cuando la lógica se reutiliza en 2+ componentes.
- También extrae cuando la lógica de hooks de un componente excede ~15 líneas y tiene una responsabilidad clara y nombrada.
- No extraigas lógica usada en solo un componente solo por abstracción.
¿Cómo manejo la seguridad SSR en un hook personalizado que accede a window o localStorage?
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
// ...
}- Protege el acceso a las APIs del navegador con
typeof window === "undefined"o mantenlo dentro deuseEffect.
Error Común: ¿Por qué devolver un nuevo objeto desde mi hook personalizado causa re-renderizados en consumidores?
- Devolver
{ value, toggle }crea una nueva referencia de objeto en cada renderizado. - Si un consumidor usa el objeto en un array de dependencias de
useEffect, el efecto se vuelve a ejecutar en cada renderizado. - Corrige envolviendo el retorno en
useMemoo devolviendo una tupla en su lugar.
¿Cómo escribo un hook personalizado genérico en TypeScript?
function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initial;
});
// ...
return [value, setValue];
}- Usa un genérico
<T>para hacer el hook reutilizable con cualquier tipo de dato.
¿Debo usar useCallback para funciones devueltas desde un hook personalizado?
- Sí. Las funciones devueltas desde hooks personalizados deben envolverse en
useCallbackpara proporcionar referencias estables. - Sin
useCallback, los consumidores que usan la función en arrays de dependencias obtienen una nueva referencia en cada renderizado. - Usa el patrón de actualizador (
setState(prev => ...)) dentro deuseCallbackpara minimizar dependencias.
¿Cómo pruebo un hook personalizado en aislamiento?
- Usa
renderHookdesde@testing-library/reactpara renderizar el hook sin un componente. - Afirma sobre los valores devueltos y simula actualizaciones con
act(). - Dado que el hook es solo una función con hooks, probarlo es sencillo una vez renderizado.
¿Pueden los hooks personalizados llamar otros hooks personalizados?
- Sí. Los hooks personalizados se componen libremente --
useAuthpuede llamaruseLocalStorage, que llamauseStateyuseEffect. - React aplana las llamadas a hooks en una única lista ordenada por componente.
- La composición profunda es una de las fortalezas principales del patrón de hook personalizado.
¿Cuál es la diferencia entre devolver una tupla vs un objeto desde un hook personalizado?
- Las tuplas (
[value, setter]) permiten a los consumidores renombrar las variables:const [name, setName] = useLocalStorage(...). - Los objetos (
{ data, error, loading }) son mejores cuando hay muchos valores de retorno y el orden no importa. - Usa tuplas para 1-3 valores; usa objetos para 4+ valores.
Relacionado
- useState — el hook más común dentro de hooks personalizados
- useEffect — suscripciones y efectos secundarios en hooks personalizados
- useCallback — estabiliza callbacks devueltos desde hooks personalizados
- useRef — valores mutables y acceso al DOM en hooks personalizados
- useMemo — memoiza valores derivados costosos dentro de hooks personalizados