Skill de Creación de Hooks Personalizados - Una receta de skill de Claude Code para crear hooks personalizados reutilizables y bien probados
Estas recetas de skills están diseñadas para Claude Code, pero también funcionan con otros agentes de codificación con IA que admitan archivos de skill/instrucciones.
Receta
El contenido completo de SKILL.md que puedes copiar en .claude/skills/custom-hooks-crafting/SKILL.md:
---
name: custom-hooks-crafting
description: "Creación de hooks personalizados reutilizables y bien probados con TypeScript adecuado. Úsalo cuando te pidan: crear un hook, hook personalizado, extraer hook, hook reutilizable, composición de hooks, probar un hook."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Creación de Hooks Personalizados
Eres un experto en la creación de hooks personalizados de React. Cada hook que crees sigue principios de diseño estrictos, está completamente tipado, es seguro para SSR y es testeable.
## Reglas de Diseño de Hooks
1. **Responsabilidad única** - Cada hook hace exactamente una cosa
2. **El nombre empieza con `use`** - Siempre usa el prefijo `use` (React lo exige)
3. **Convenciones de tipo de retorno:**
- Valor único: devuelve el valor directamente
- Valor + setter: devuelve una tupla `[value, setter]` (como useState)
- Múltiples valores relacionados: devuelve un objeto `{ value, loading, error }`
4. **Acepta la configuración como objeto** - Cuando un hook toma más de 2 parámetros, usa un objeto de opciones
5. **Proporciona valores predeterminados sensatos** - Cada opción debe tener un valor predeterminado
6. **Limpia después de ti** - Devuelve siempre funciones de limpieza desde useEffect
7. **Seguridad SSR** - Comprueba `typeof window !== "undefined"` antes de acceder a APIs del navegador
8. **Referencias estables** - Envuelve las funciones devueltas en useCallback; envuelve los objetos devueltos en useMemo
## Plantilla de Creación de Hooks
```tsx
import \{ useState, useEffect, useCallback, useRef \} from "react";
interface UseMyHookOptions \{
/** Descripción de la opción */
enabled?: boolean;
/** Descripción de la opción */
interval?: number;
\}
interface UseMyHookReturn \{
/** Descripción del valor de retorno */
data: string | null;
/** Descripción del valor de retorno */
loading: boolean;
/** Descripción del valor de retorno */
error: Error | null;
/** Descripción del valor de retorno */
reset: () => void;
\}
/**
* Descripción de lo que hace el hook y cuándo usarlo.
*
* @example
* const \{ data, loading, error \} = useMyHook(\{ enabled: true \});
*/
export function useMyHook(options: UseMyHookOptions = \{\}): UseMyHookReturn \{
const \{ enabled = true, interval = 1000 \} = options;
const [data, setData] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Usa ref para valores necesarios en efectos que no deben provocar re-ejecuciones
const intervalRef = useRef(interval);
intervalRef.current = interval;
useEffect(() => \{
if (!enabled) return;
let cancelled = false;
setLoading(true);
async function fetchData() \{
try \{
const result = await someAsyncOperation();
if (!cancelled) \{
setData(result);
setLoading(false);
\}
\} catch (err) \{
if (!cancelled) \{
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
\}
\}
\}
fetchData();
return () => \{ cancelled = true; \};
\}, [enabled]);
const reset = useCallback(() => \{
setData(null);
setLoading(false);
setError(null);
\}, []);
return \{ data, loading, error, reset \};
\}Recetas Comunes de Hooks
useDebounce
export function useDebounce<T>(value: T, delay: number): T \{
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => \{
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
\}, [value, delay]);
return debouncedValue;
\}useMediaQuery
export function useMediaQuery(query: string): boolean \{
const [matches, setMatches] = useState(false);
useEffect(() => \{
if (typeof window === "undefined") return;
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
\}, [query]);
return matches;
\}useClickOutside
export function useClickOutside<T extends HTMLElement>(
handler: () => void
): React.RefObject<T | null> \{
const ref = useRef<T | null>(null);
useEffect(() => \{
const listener = (event: MouseEvent | TouchEvent) => \{
if (!ref.current || ref.current.contains(event.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;
\}useLocalStorage
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] \{
const [storedValue, setStoredValue] = useState<T>(() => \{
if (typeof window === "undefined") return initialValue;
try \{
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
\} catch \{
return initialValue;
\}
\});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => \{
setStoredValue((prev) => \{
const next = value instanceof Function ? value(prev) : value;
if (typeof window !== "undefined") \{
window.localStorage.setItem(key, JSON.stringify(next));
\}
return next;
\});
\},
[key]
);
return [storedValue, setValue];
\}useIntersectionObserver
interface UseIntersectionOptions \{
threshold?: number;
rootMargin?: string;
triggerOnce?: boolean;
\}
export function useIntersectionObserver<T extends HTMLElement>(
options: UseIntersectionOptions = \{\}
): [React.RefObject<T | null>, boolean] \{
const \{ threshold = 0, rootMargin = "0px", triggerOnce = false \} = options;
const ref = useRef<T | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => \{
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
([entry]) => \{
setIsVisible(entry.isIntersecting);
if (entry.isIntersecting && triggerOnce) \{
observer.unobserve(element);
\}
\},
\{ threshold, rootMargin \}
);
observer.observe(element);
return () => observer.disconnect();
\}, [threshold, rootMargin, triggerOnce]);
return [ref, isVisible];
\}usePrevious
export function usePrevious<T>(value: T): T | undefined \{
const ref = useRef<T | undefined>(undefined);
useEffect(() => \{
ref.current = value;
\});
return ref.current;
\}Cuándo Extraer un Hook
Extrae un hook personalizado cuando:
- El mismo patrón useState + useEffect aparece en 2 o más componentes
- Un componente tiene lógica de state compleja que oscurece la lógica de renderizado
- Necesitas compartir comportamiento (no UI) entre componentes
- Probar la lógica de forma independiente del componente sería valioso
NO extraigas un hook cuando:
- La lógica se usa en un solo componente y es simple
- Solo estás envolviendo un único useState (añade indirección sin beneficio)
- El "hook" no usa ningún hook de React internamente (conviértelo en una función simple)
Plantilla de Pruebas
import \{ renderHook, act, waitFor \} from "@testing-library/react";
import \{ useMyHook \} from "./useMyHook";
describe("useMyHook", () => \{
it("returns initial state", () => \{
const \{ result \} = renderHook(() => useMyHook());
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
\});
it("updates state on action", () => \{
const \{ result \} = renderHook(() => useMyHook(\{ enabled: true \}));
act(() => \{
result.current.reset();
\});
expect(result.current.data).toBeNull();
\});
it("handles async operations", async () => \{
const \{ result \} = renderHook(() => useMyHook(\{ enabled: true \}));
await waitFor(() => \{
expect(result.current.loading).toBe(false);
\});
expect(result.current.data).toBeDefined();
\});
it("cleans up on unmount", () => \{
const \{ unmount \} = renderHook(() => useMyHook());
// Verifica que no hay errores ni advertencias al desmontar
expect(() => unmount()).not.toThrow();
\});
it("responds to option changes", () => \{
const \{ result, rerender \} = renderHook(
(\{ enabled \}) => useMyHook(\{ enabled \}),
\{ initialProps: \{ enabled: false \} \}
);
expect(result.current.loading).toBe(false);
rerender(\{ enabled: true \});
expect(result.current.loading).toBe(true);
\});
\});Reglas de Composición de Hooks
- Los hooks pueden llamar a otros hooks (ese es el modelo de composición)
- Mantén la cadena de dependencias poco profunda (hook A -> hook B está bien; A -> B -> C -> D es una señal de alerta)
- Si compones hooks, asegúrate de que la limpieza se propague correctamente
- Usa refs para romper ciclos de dependencias en efectos
## Ejemplo Funcional
### Ejemplo 1: El usuario pide "Crea un hook para copiar texto al portapapeles"
**Prompt del usuario:** "Necesito un hook que copie texto al portapapeles y muestre un estado de éxito durante 2 segundos."
**La respuesta guiada por el skill produciría:**
```tsx
// hooks/use-clipboard.ts
import { useState, useCallback, useRef } from "react";
interface UseClipboardOptions {
/** Cuánto tiempo mostrar el estado copiado en ms */
successDuration?: number;
}
interface UseClipboardReturn {
copied: boolean;
copy: (text: string) => Promise<void>;
error: Error | null;
}
export function useClipboard(
options: UseClipboardOptions = {}
): UseClipboardReturn {
const { successDuration = 2000 } = options;
const [copied, setCopied] = useState(false);
const [error, setError] = useState<Error | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const copy = useCallback(
async (text: string) => {
if (typeof navigator === "undefined" || !navigator.clipboard) {
setError(new Error("La API del portapapeles no está disponible"));
return;
}
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setError(null);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), successDuration);
} catch (err) {
setError(err instanceof Error ? err : new Error("Error al copiar"));
setCopied(false);
}
},
[successDuration]
);
return { copied, copy, error };
}
// hooks/use-clipboard.test.ts
import { renderHook, act } from "@testing-library/react";
import { useClipboard } from "./use-clipboard";
// Mock de la API del portapapeles
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
describe("useClipboard", () => {
it("copies text and sets copied to true", async () => {
const { result } = renderHook(() => useClipboard());
await act(async () => {
await result.current.copy("hello");
});
expect(result.current.copied).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
});
it("resets copied after successDuration", async () => {
vi.useFakeTimers();
const { result } = renderHook(() =>
useClipboard({ successDuration: 1000 })
);
await act(async () => {
await result.current.copy("hello");
});
expect(result.current.copied).toBe(true);
act(() => vi.advanceTimersByTime(1000));
expect(result.current.copied).toBe(false);
vi.useRealTimers();
});
});Ejemplo 2: El usuario pide "Extrae esta lógica repetida en un hook"
La respuesta guiada por el skill: Identificaría el patrón compartido de state/efecto, lo extraería siguiendo la plantilla, añadiría tipos TypeScript adecuados, garantizaría la seguridad SSR y proporcionaría pruebas.
Profundización
Cómo Funciona el Skill
Este skill proporciona:
- Reglas de diseño - Nomenclatura, tipos de retorno, convenciones de parámetros
- Plantilla de creación - Un punto de partida listo para copiar y pegar para cualquier hook nuevo
- Biblioteca de recetas - 6 implementaciones de hooks listas para producción
- Plantilla de pruebas - Estructura de pruebas estandarizada para hooks
- Criterios de extracción - Cuándo extraer y cuándo no
Personalización
- Añade las convenciones de nomenclatura de hooks de tu proyecto
- Incluye hooks específicos del proyecto como ejemplos
- Especifica preferencias del framework de pruebas (Vitest vs Jest)
- Añade requisitos de rendimiento (p. ej., "todos los hooks deben admitir React Compiler")
Cómo Instalar
mkdir -p .claude/skills/custom-hooks-crafting
# Pega el contenido de Receta en .claude/skills/custom-hooks-crafting/SKILL.mdErrores Comunes
- La limpieza de useEffect se ejecuta antes de cada re-ejecución, no solo al desmontar - Diseña las funciones de limpieza para manejar ambos casos.
- Las funciones inicializadoras de useState se ejecutan solo una vez -
useState(() => expensiveComputation())es distinto deuseState(expensiveComputation()). - Los refs no provocan re-renderizados - Si necesitas que la UI se actualice, necesitas state, no refs.
- Los hooks personalizados comparten lógica, no state - Dos componentes que llaman al mismo hook obtienen cada uno su propio state independiente.
- Los hooks no pueden llamarse condicionalmente - Pero
use()en React 19 sí puede.
Alternativas
| Enfoque | Cuándo Usarlo |
|---|---|
| Funciones de utilidad simples | Lógica que no necesita hooks de React |
| Higher-order components | Envolver comportamiento alrededor de un componente (patrón heredado) |
| Render props | Compartir comportamiento con control del renderizado (patrón heredado) |
| Selectores de Zustand | State compartido entre componentes (no solo lógica compartida) |
Preguntas Frecuentes
¿Cuándo debes extraer lógica a un hook personalizado en lugar de mantenerla en el componente?
- Extrae cuando el mismo patrón
useState+useEffectaparece en 2 o más componentes. - Extrae cuando la lógica de state compleja oscurece la lógica de renderizado.
- No extraigas si la lógica se usa en un componente y es simple, o si solo envuelve un único
useState.
¿Por qué los nombres de hooks personalizados deben empezar con "use"?
- React exige esta convención para identificar funciones que pueden llamar a otros hooks.
- El linter usa el prefijo
usepara aplicar las Rules of Hooks (sin llamadas condicionales, sin llamadas en bucles). - Sin el prefijo, React no puede verificar el uso correcto de hooks en tiempo de lint.
¿Cuál es la convención correcta de tipo de retorno para un hook personalizado?
- Valor único: devuélvelo directamente.
- Valor + setter: devuelve una tupla
[value, setter](comouseState). - Múltiples valores relacionados: devuelve un objeto
{ value, loading, error }.
¿Cómo tipas un hook personalizado genérico como useLocalStorage en TypeScript?
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// T se infiere desde initialValue
}¿Cómo haces un hook personalizado seguro para SSR?
- Comprueba
typeof window !== "undefined"antes de acceder a APIs del navegador comolocalStorage,navigatoromatchMedia. - Devuelve un valor predeterminado sensato durante SSR (p. ej.,
falseparauseMediaQuery).
Trampa común: ¿Dos componentes que llaman al mismo hook personalizado comparten state?
- No. Cada componente obtiene su propia instancia de state independiente.
- Los hooks personalizados comparten lógica, no state.
- Para state compartido, usa una biblioteca de gestión de state como Zustand o React Context.
¿Por qué las funciones devueltas por hooks deben envolverse en useCallback?
- Sin
useCallback, la referencia de la función cambia en cada renderizado. - Los componentes hijos que reciben la función como prop se re-renderizarán innecesariamente.
- Las referencias estables evitan re-renderizados en cascada.
¿Cómo pruebas un hook personalizado con React Testing Library?
import { renderHook, act } from "@testing-library/react";
import { useMyHook } from "./useMyHook";
it("returns initial state", () => {
const { result } = renderHook(() => useMyHook());
expect(result.current.data).toBeNull();
});
it("updates on action", () => {
const { result } = renderHook(() => useMyHook());
act(() => result.current.reset());
expect(result.current.data).toBeNull();
});Trampa común: ¿Cuál es la diferencia entre useState(fn) y useState(fn())?
useState(() => expensiveComputation())ejecuta la función solo una vez en el renderizado inicial (inicializador perezoso).useState(expensiveComputation())ejecuta la función en cada renderizado, desperdiciando cómputo.
¿Cuál es el patrón useRef para evitar cierres obsoletos en useEffect?
- Almacena la callback más reciente en un ref:
handlerRef.current = handler. - En el efecto, llama a
handlerRef.current(...)en lugar dehandler(...). - Esto permite que el cierre del efecto se mantenga estable (deps vacías) mientras siempre llama a la función más reciente.
¿Cómo tipas el valor de retorno de useClickOutside en TypeScript?
function useClickOutside<T extends HTMLElement>(
handler: () => void
): React.RefObject<T | null> {
const ref = useRef<T | null>(null);
// adjunta listeners al document, comprueba ref.current.contains
return ref;
}¿Cuál es la profundidad máxima recomendada para cadenas de composición de hooks?
- Mantenla poco profunda: el hook A llamando al hook B está bien; A llamando a B llamando a C llamando a D es una señal de alerta.
- Las cadenas profundas dificultan la propagación de limpieza y la depuración.
- Si necesitas composición profunda, considera refactorizar en menos hooks más enfocados.