Patrones de hooks en React: más allá de useState y useEffect
Hooks personalizados, patrones de composición y sus reglas: patrones prácticos para gestionar estado complejo y efectos sin componentes de clase.

Los hooks reemplazaron a los componentes de clase como la forma estándar de gestionar el estado y los efectos secundarios en React. Pero más allá de useState y useEffect, existe un rico conjunto de patrones para extraer lógica en hooks personalizados reutilizables, gestionar transiciones de estado complejas y evitar los errores habituales que provocan closures obsoletos (stale closures) y renderizados infinitos.
Hooks personalizados: extraer lógica reutilizable
Un hook personalizado es una función que utiliza otros hooks. Encapsula lógica con estado que puede compartirse entre componentes.
// ❌ Duplicated fetch logic in every component
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
// Same pattern copied into OrderList, ProductPage, etc.
}
// ✅ Extracted into a reusable hook
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(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(setData)
.catch(err => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Clean usage in any component
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <ProfileCard user={user!} />;
}useReducer para estado complejo
Cuando las transiciones de estado dependen del estado actual o involucran varios valores relacionados, useReducer resulta más claro que múltiples llamadas a useState.
// ❌ Multiple useState calls with coupled updates
function OrderForm() {
const [items, setItems] = useState<OrderItem[]>([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [status, setStatus] = useState<"idle" | "submitting" | "error">("idle");
function addItem(item: OrderItem) {
setItems([...items, item]);
setTotal(total + item.price);
// Easy to forget updating total when removing items
}
}
// ✅ useReducer keeps related state transitions consistent
type OrderAction =
| { type: "ADD_ITEM"; item: OrderItem }
| { type: "REMOVE_ITEM"; itemId: string }
| { type: "APPLY_DISCOUNT"; percentage: number }
| { type: "SUBMIT" }
| { type: "SUBMIT_ERROR"; error: string };
interface OrderState {
items: OrderItem[];
total: number;
discount: number;
status: "idle" | "submitting" | "error";
error: string | null;
}
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "ADD_ITEM":
return {
...state,
items: [...state.items, action.item],
total: state.items.reduce((sum, i) => sum + i.price, 0) + action.item.price,
};
case "REMOVE_ITEM":
const filtered = state.items.filter(i => i.id !== action.itemId);
return {
...state,
items: filtered,
total: filtered.reduce((sum, i) => sum + i.price, 0),
};
case "APPLY_DISCOUNT":
return { ...state, discount: action.percentage };
case "SUBMIT":
return { ...state, status: "submitting", error: null };
case "SUBMIT_ERROR":
return { ...state, status: "error", error: action.error };
default:
return state;
}
}useCallback y useMemo: evitar trabajo innecesario
Memoiza los callbacks para evitar tanto renderizados innecesarios en los componentes hijos como la repetición de cálculos costosos en cada renderizado.
// ❌ New function reference on every render → child re-renders
function ParentComponent({ items }: { items: Item[] }) {
const handleSelect = (id: string) => {
console.log("Selected:", id);
};
// filteredItems recalculated on every render, even if items hasn't changed
const filteredItems = items.filter(i => i.active);
return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
// ✅ Stable references with useCallback and useMemo
function ParentComponent({ items }: { items: Item[] }) {
const handleSelect = useCallback((id: string) => {
console.log("Selected:", id);
}, []);
const filteredItems = useMemo(
() => items.filter(i => i.active),
[items]
);
return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
// Only memoize the child if it's expensive to render
const ItemList = React.memo(function ItemList({
items,
onSelect,
}: {
items: Item[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});useRef para valores persistentes sin renderizados
Los refs almacenan valores mutables que persisten entre renderizados sin provocar un nuevo renderizado cuando cambian.
function useInterval(callback: () => void, delayMs: number) {
const savedCallback = useRef(callback);
// Update the ref when callback changes — no re-render needed
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delayMs);
return () => clearInterval(id);
}, [delayMs]);
}
// Usage: callback can change without resetting the interval
function PollComponent() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1); // Always uses latest count
}, 1000);
return <div>Count: {count}</div>;
}Cómo evitar closures obsoletos
El error más común con los hooks: un callback captura un valor antiguo de un renderizado anterior.
// ❌ Stale closure — count is always 0 inside the timeout
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
console.log(`Count is: ${count}`); // Always logs 0
}, 5000);
return () => clearTimeout(id);
}, []); // Empty deps → closure captures initial count (0)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
// ✅ Use a ref to read the latest value
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setTimeout(() => {
console.log(`Count is: ${countRef.current}`); // Reads current value
}, 5000);
return () => clearTimeout(id);
}, []);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}Puntos clave
- Los hooks personalizados extraen lógica con estado reutilizable — comparten comportamiento entre componentes sin render props ni HOCs
- useReducer gestiona transiciones de estado complejas — mantiene coherentes las actualizaciones de estado relacionadas en un único reducer
- Memoiza de forma estratégica — useCallback y useMemo evitan trabajo innecesario, pero solo cuando el componente hijo realmente usa React.memo
- useRef para valores que no deberían provocar renderizados — IDs de temporizadores, referencias al DOM y referencias al último callback
- Presta atención a los closures obsoletos — los efectos con arreglos de dependencias vacíos capturan valores iniciales; usa refs para leer los valores actuales
- Sigue las reglas de los hooks — llama siempre a los hooks en el nivel superior, nunca dentro de condiciones o bucles


