Saltar al contenido

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.

4 min de lectura
Diagrama de composición de hooks de React que muestra hooks personalizados envolviendo useState y useEffect

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.

tsxtsx
// ❌ 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.

tsxtsx
// ❌ 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.

tsxtsx
// ❌ 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.

tsxtsx
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.

tsxtsx
// ❌ 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

  1. Los hooks personalizados extraen lógica con estado reutilizable — comparten comportamiento entre componentes sin render props ni HOCs
  2. useReducer gestiona transiciones de estado complejas — mantiene coherentes las actualizaciones de estado relacionadas en un único reducer
  3. Memoiza de forma estratégica — useCallback y useMemo evitan trabajo innecesario, pero solo cuando el componente hijo realmente usa React.memo
  4. useRef para valores que no deberían provocar renderizados — IDs de temporizadores, referencias al DOM y referencias al último callback
  5. 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
  6. Sigue las reglas de los hooks — llama siempre a los hooks en el nivel superior, nunca dentro de condiciones o bucles
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX