useState y useEffect: cómo usarlos correctamente

1. Descripción general

useState y useEffect son los dos hooks más básicos de React, pero también son donde más bugs surgen: componentes que se renderizan infinitamente, state que no se actualiza como esperas, effects que se ejecutan innecesariamente una y otra vez. Este artículo recorre el mecanismo real de funcionamiento de los dos hooks, los patrones correctos y los errores comunes que deberías evitar. Después de leerlo, tendrás más confianza a la hora de decidir “¿realmente necesito useEffect aquí?” — una pregunta que muchos desarrolladores de React aún responden incorrectamente.

2. useState básico

useState devuelve un par [value, setter]. Cada vez que llamas al setter, React programa un nuevo render del componente con el nuevo valor.

import { useState } from "react";

function CustomerCounter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Número de clientes: {count}</p>
      <button onClick={() => setCount(count + 1)}>Añadir cliente</button>
    </div>
  );
}
  • useState(0) inicializa count = 0 solo en el primer render.
  • setCount(count + 1) crea un nuevo render con count incrementado en 1.
  • count dentro del closure del render actual es immutable — no cambia por sí solo entre dos renders.

Nota: Llamar a setCount no cambia el valor de count inmediatamente en el mismo render. React agrupa las actualizaciones y renderiza después.

3. useState: actualizar basándose en el state anterior

Si llamas al setter varias veces dentro del mismo event handler basándote en count (variable “congelada”), el resultado será incorrecto.

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Resultado: count solo aumenta en 1, no en 3
}

Usa actualizaciones funcionales para obtener el valor más reciente:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // Resultado: count aumenta correctamente en 3
}
  • Cuando pasas una función al setter, React garantiza que prev siempre es el valor más reciente del state en el momento del procesamiento, no el valor capturado en el closure antiguo.
  • Regla: si el nuevo state depende del state anterior, siempre usa actualizaciones funcionales.

4. useState: lazy initial state

Si el valor inicial requiere cálculos costosos, no llames a la función directamente — pasa la función a useState para que se ejecute solo una vez.

function parseInitialOrders(raw) {
  console.log("Parseando datos de pedidos...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // MAL: parseInitialOrders se ejecuta en cada render
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // BIEN: se ejecuta solo en el primer render
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>Número de pedidos: {orders.length}</p>;
}
  • La forma incorrecta llama a parseInitialOrders en cada render, aunque React solo use el resultado del primero.
  • La forma correcta pasa una referencia de función; React solo la invoca cuando monta por primera vez.

5. useEffect básico

useEffect ejecuta un efecto secundario después de que el componente se renderice (después de que el DOM se haya actualizado).

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `Tienes ${orderCount} pedidos`;
  });

  return <p>Pedido actual: {orderCount}</p>;
}
  • Sin un dependency array (la segunda parte), este efecto se ejecuta después de cada render — tanto en mount como en update.
  • document.title es un efecto secundario típico: interactúa con el mundo fuera de React (API del DOM), no es un cálculo de valor para renderizar.

6. useEffect: dependency array

El dependency array determina cuándo se ejecuta el efecto. Hay 3 casos que debes recordar bien.

6.1. Sin dependency array

useEffect(() => {
  console.log("Se ejecuta después de CADA render");
});

6.2. Dependency array vacío []

useEffect(() => {
  console.log("Se ejecuta UNA SOLA VEZ después del mount");
}, []);

6.3. Dependency array con valores

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "Cargando..."}</p>;
}
  • El efecto solo se ejecuta nuevamente cuando customerId cambia entre dos renders (comparado usando Object.is).
  • Si olvidas incluir customerId en el array, el efecto usará el valor antiguo de customerId (stale closure) — obtendrá el cliente incorrecto cuando el prop cambie.

Nota: El plugin ESLint eslint-plugin-react-hooks con la regla exhaustive-deps te alertará cuando falte una dependencia. No desactives esta regla a menos que entiendas claramente las consecuencias.

7. useEffect: función de limpieza

Cuando un efecto crea una subscription, un timer o un event listener, debes devolver una función de limpieza para evitar pérdidas de memoria.

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>Estado: {status}</p>;
}
  • La función de limpieza se ejecuta antes de que el efecto se ejecute nuevamente (cuando orderId cambia) y cuando el componente se desmonta.
  • Si no limpias, cada vez que orderId cambie se creará una nueva subscription — eventualmente setStatus será llamado desde múltiples subscriptions antiguas, causando actualizaciones incorrectas o pérdidas de memoria.
Orden de ejecución cuando orderId cambia de 1 a 2:
1. Limpieza del efecto con orderId = 1
2. Nuevo efecto se ejecuta con orderId = 2

8. Errores comunes

8.1. Bucle infinito por faltar dependencias correctas

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // nuevo object en cada render -> effect siempre ve "cambio" si pones data en deps
    setData({ count: data.count + 1 }); // causa re-render -> effect se ejecuta de nuevo -> infinito
  }, [data]);

  return <p>{data.count}</p>;
}

Arregla usando actualizaciones funcionales y separando la condición de parada, o elimina useEffect completamente si esta lógica no necesita un efecto secundario real.

8.2. Stale closure en setTimeout/setInterval

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // usa actualización funcional, NO uses seconds directamente
    }, 1000);

    return () => clearInterval(id);
  }, []); // deps vacío porque no lees seconds directamente en el efecto

  return <p>{seconds}</p>;
}
  • Si escribes setSeconds(seconds - 1) en lugar de usar actualización funcional, el closure siempre “recordará” seconds = 10 del primer ejecutar del efecto — la cuenta regresiva se quedará atrapada en un valor incorrecto.

8.3. Usar useEffect para calcular un valor derivado

// MAL: innecesario, causa un render extra
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>Total: {total}</p>;
}

// BIEN: calcula directamente durante el render
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Total: {total}</p>;
}

La versión “MAL” causa un render extra: renderiza con el total anterior, el efecto se ejecuta, setTotal dispara un segundo render con el valor correcto. La versión “BIEN” calcula durante el render, sin necesidad de efecto, sin state auxiliar.

9. Cuándo NO deberías usar useEffect

Nota: useEffect es solo para sincronizar con sistemas fuera de React (DOM, API, subscriptions, timers). Si puedes calcular un valor directamente desde los props/state actuales, no uses un efecto.

Tres señales de que lo estás usando mal:

  • Usas un efecto para calcular un valor derivado de otro state (usa una variable normal o useMemo).
  • Usas un efecto para manejar eventos del usuario (manejalo directamente en el handler onClick, onSubmit).
  • Usas un efecto para resetear state cuando un prop cambia — generalmente puedes usar el prop key para que React remonte el componente automáticamente en lugar de usar un efecto.
// En lugar de usar un efecto para resetear el formulario cuando customerId cambia:
// useEffect(() => { setForm(initialForm); }, [customerId]);

// Usa key para que React remonte el componente, así React resetea automáticamente todo el state interno:
<CustomerForm key={customerId} customerId={customerId} />

10. Escribir tests para el comportamiento del hook

Usa Jest con React Testing Library para verificar el comportamiento real en lugar de detalles de implementación.

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("incrementa count al hacer clic en el botón Añadir cliente", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("Número de clientes: 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Añadir cliente" }));

  expect(screen.getByText("Número de clientes: 1")).toBeInTheDocument();
});
  • El test llama a render y luego simula un clic real mediante userEvent, como lo haría un usuario.
  • Las aserciones se basan en el contenido mostrado (getByText), no en el state interno — esta es la forma correcta de probar en React: prueba el comportamiento, no la implementación.
PASS  ./CustomerCounter.test.jsx
  ✓ incrementa count al hacer clic en el botón Añadir cliente (45 ms)

11. Conclusión

useState parece simple en la superficie pero es fácil cometer errores cuando olvidas que el valor en el closure está “congelado” en cada render — usa actualizaciones funcionales cuando el nuevo state dependa del anterior. useEffect es poderoso pero solo deberías usarlo para sincronizar con sistemas fuera de React; si un valor puede calcularse directamente durante el render, no lo envuelvas en un efecto. Dominar el dependency array y la función de limpieza te ayudará a evitar casi todos los bugs comunes relacionados con estos dos hooks.

reacthooksjavascriptfrontendtypescript