Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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 \};
\}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;
\}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;
\}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;
\}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];
\}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];
\}export function usePrevious<T>(value: T): T | undefined \{
const ref = useRef<T | undefined>(undefined);
useEffect(() => \{
ref.current = value;
\});
return ref.current;
\}Extrae un hook personalizado cuando:
NO extraigas un hook cuando:
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);
\});
\});
## 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();
});
});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.
Este skill proporciona:
mkdir -p .claude/skills/custom-hooks-crafting
# Pega el contenido de Receta en .claude/skills/custom-hooks-crafting/SKILL.mduseState(() => expensiveComputation()) es distinto de useState(expensiveComputation()).use() en React 19 sí puede.| 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) |
useState + useEffect aparece en 2 o más componentes.useState.use para aplicar las Rules of Hooks (sin llamadas condicionales, sin llamadas en bucles).[value, setter] (como useState).{ value, loading, error }.function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// T se infiere desde initialValue
}typeof window !== "undefined" antes de acceder a APIs del navegador como localStorage, navigator o matchMedia.false para useMediaQuery).useCallback, la referencia de la función cambia en cada renderizado.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();
});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.handlerRef.current = handler.handlerRef.current(...) en lugar de handler(...).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;
}Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥