Creando hooks de React reutilizables: patrones y antipatrones
Cómo diseñar hooks de React realmente reutilizables: patrones de composición, estrategias de testing, encapsulación de estado y antipatrones a evitar.

Los hooks personalizados son la abstracción principal de React para compartir lógica con estado entre componentes. Pero la mayoría de los hooks personalizados no son realmente reutilizables: son solo envoltorios de useEffect que mueven la lógica del componente a un archivo separado sin mejorar la API. Un hook bien diseñado encapsula la complejidad, expone una interfaz mínima y se compone con otros hooks de forma natural.
La diferencia entre un hook reutilizable y una extracción puntual es la misma que hay entre una función de biblioteca y código copiado y pegado. Una está diseñada para quienes la consumen. La otra simplemente está en un archivo separado.
El hook de responsabilidad única
Cada hook debería gestionar una sola responsabilidad. Si tu hook hace fetching, caché y polling, son tres hooks disfrazados con un abrigo.
// ❌ Kitchen-sink hook — does too much
function useUserDashboard(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]).then(([u, p, n]) => {
setUser(u);
setPosts(p);
setNotifications(n);
setLoading(false);
});
}, [userId]);
return { user, posts, notifications, loading };
}
// Can't reuse the fetch logic independently
// Can't use posts fetching without notifications// ✅ Focused hooks that compose
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(result => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Compose in the component
function UserDashboard({ userId }: { userId: string }) {
const user = useFetch<User>(`/api/users/${userId}`);
const posts = useFetch<Post[]>(`/api/users/${userId}/posts`);
const notifications = useFetch<Notification[]>(`/api/notifications`);
if (user.loading) return <Skeleton />;
// Each data source is independent
}Referencias estables con useCallback y useMemo
Los hooks que devuelven funciones u objetos crean nuevas referencias en cada render, lo que rompe React.memo y provoca re-renders innecesarios en los componentes hijos.
// ❌ New object reference on every render
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
// This function is recreated every render
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
// New object every render — consumers always re-render
return { value, setValue: setStoredValue };
}// ✅ Stable references with useCallback
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
const setStoredValue = useCallback(
(newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prev)
: newValue;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
},
[key]
);
return [value, setStoredValue] as const;
}
// Usage — tuple return like useState
const [theme, setTheme] = useLocalStorage('theme', 'light');
setTheme('dark');
setTheme(prev => prev === 'light' ? 'dark' : 'light');El retorno en forma de tupla ([value, setter]) imita a useState, lo que hace que la API resulte familiar. El useCallback garantiza que la función setter tenga una referencia estable.
Encapsulando máquinas de estado complejas
Los hooks brillan cuando ocultan la complejidad de una máquina de estados detrás de una interfaz simple.
// ❌ Exposing state machine internals to the component
function FileUploader() {
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const upload = async () => {
if (!file) return;
setStatus('uploading');
setProgress(0);
try {
await uploadFile(file, (p) => setProgress(p));
setStatus('done');
} catch (err) {
setStatus('error');
setError(err.message);
}
};
const reset = () => {
setFile(null);
setProgress(0);
setStatus('idle');
setError(null);
};
// Component manages all state transitions
}// ✅ Hook encapsulates the state machine
interface UploadState {
status: 'idle' | 'selecting' | 'uploading' | 'done' | 'error';
file: File | null;
progress: number;
error: string | null;
url: string | null;
}
function useFileUpload(options?: { maxSizeMB?: number; accept?: string[] }) {
const [state, setState] = useState<UploadState>({
status: 'idle',
file: null,
progress: 0,
error: null,
url: null,
});
const selectFile = useCallback((file: File) => {
const maxSize = (options?.maxSizeMB ?? 10) * 1024 * 1024;
if (file.size > maxSize) {
setState(s => ({
...s,
status: 'error',
error: `File too large (max ${options?.maxSizeMB ?? 10}MB)`,
}));
return;
}
if (options?.accept && !options.accept.includes(file.type)) {
setState(s => ({
...s,
status: 'error',
error: `Invalid file type. Accepted: ${options.accept!.join(', ')}`,
}));
return;
}
setState({ status: 'selecting', file, progress: 0, error: null, url: null });
}, [options?.maxSizeMB, options?.accept]);
const upload = useCallback(async () => {
if (!state.file || state.status !== 'selecting') return;
setState(s => ({ ...s, status: 'uploading', progress: 0 }));
try {
const url = await uploadFile(state.file, (progress) => {
setState(s => ({ ...s, progress }));
});
setState(s => ({ ...s, status: 'done', url, progress: 100 }));
} catch (err) {
setState(s => ({
...s,
status: 'error',
error: err instanceof Error ? err.message : 'Upload failed',
}));
}
}, [state.file, state.status]);
const reset = useCallback(() => {
setState({
status: 'idle',
file: null,
progress: 0,
error: null,
url: null,
});
}, []);
return {
...state,
selectFile,
upload,
reset,
canUpload: state.status === 'selecting',
} as const;
}
// Clean component usage
function FileUploader() {
const uploader = useFileUpload({ maxSizeMB: 5, accept: ['image/png', 'image/jpeg'] });
return (
<div>
<input type="file" onChange={e => {
if (e.target.files?.[0]) uploader.selectFile(e.target.files[0]);
}} />
{uploader.canUpload && <button onClick={uploader.upload}>Upload</button>}
{uploader.status === 'uploading' && <Progress value={uploader.progress} />}
{uploader.status === 'done' && <p>Uploaded: {uploader.url}</p>}
{uploader.error && <p className="error">{uploader.error}</p>}
</div>
);
}El componente no gestiona las transiciones de estado. Llama a acciones (selectFile, upload, reset) y lee estado derivado (canUpload, status). El hook es dueño de toda la lógica de validación y transiciones.
Testing de hooks personalizados
Prueba los hooks a través de su API pública —los valores y funciones que devuelven—, no de su estado interno.
import { renderHook, act } from '@testing-library/react';
describe('useLocalStorage', () => {
beforeEach(() => localStorage.clear());
it('returns initial value when storage is empty', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
it('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
act(() => {
result.current[1]('dark');
});
expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
it('supports updater function', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
act(() => {
result.current[1](prev => prev + 1);
});
expect(result.current[0]).toBe(1);
});
it('reads existing value from storage', () => {
localStorage.setItem('theme', '"dark"');
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('dark');
});
});renderHook renderiza el hook en un componente de prueba aislado. act envuelve las actualizaciones de estado. Prueba los valores devueltos y los comportamientos, no los detalles de implementación.
Antipatrones que debes evitar
// ❌ Anti-pattern 1: Hook that just wraps a single useEffect
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
// This is so thin it adds indirection without abstraction value.
// Just write the useEffect in the component.
// ❌ Anti-pattern 2: Hook that takes too many config options
function useFetch(url, {
method, headers, body, cache, timeout, retries,
retryDelay, transform, onSuccess, onError,
dedupe, polling, pollingInterval, ...rest
}) { /* ... */ }
// This is a fetch wrapper, not a hook. Use a library.
// ❌ Anti-pattern 3: Hook that returns too many values
function useForm() {
return {
values, errors, touched, dirty, isValid, isSubmitting,
handleChange, handleBlur, handleSubmit, setFieldValue,
setFieldError, setFieldTouched, resetForm, validateField,
validateForm, registerField, unregisterField,
};
}
// Return an object with methods on it instead of 15 loose valuesIdeas clave
- Un hook, una responsabilidad: si hace fetching, caché y polling, divídelo en tres hooks
- Estabiliza las referencias devueltas con
useCallbackyuseMemo: las referencias inestables rompenReact.memo - Encapsula las máquinas de estado: expón acciones y estado derivado, no funciones setState crudas
- Devuelve tuplas para hooks simples (
[value, setter]) y objetos para los complejos - Prueba a través de la API pública: haz aserciones sobre los valores devueltos, no sobre el estado interno
- Evita los envoltorios triviales: si el hook es solo un
useEffect, añade indirección sin aportar valor


