B / Blog sobre React

Probablemente no necesitas ese useEffect

Cinco patrones habituales de React donde useEffect es innecesario y cómo refactorizar cada uno para obtener un código más sencillo y predecible.

Enfoque
Arquitectura React
Publicado
Tiempo estimado de lectura
15 min de lectura
  • Patrones React
  • useEffect
  • Gestión de estado
  • Refactorización

useEffect es una de las API de React que se malinterpretan con más frecuencia.

A menudo se convierte en la solución por defecto cuando un componente debe hacer algo después de renderizar: calcular un valor, actualizar otra parte del estado, responder a un clic, reiniciar un formulario o mantener sincronizadas dos variables de estado. El problema es que muchas de estas situaciones no necesitan ningún efecto.

Usar useEffect innecesariamente puede introducir renderizados adicionales, una UI temporalmente incoherente, un flujo de datos más complicado, errores en el array de dependencias, bucles infinitos, estado que queda obsoleto y código más difícil de entender y probar.

Cuando la lógica solo implica props, estado, renderizado o eventos de usuario de React, normalmente puedes resolverla sin un efecto.

Esta guía cubre cinco situaciones habituales donde useEffect es innecesario y muestra cómo refactorizar cada una.

B-01

1. Estado derivado

El estado derivado es un valor que puede calcularse a partir de props o estado ya existentes. Imagina un carrito de compra que almacena tanto sus artículos como el precio total.

Antes: calcular estado derivado en un efecto

import { useEffect, useState } from "react";

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type CartProps = {
  items: CartItem[];
};

export function Cart({ items }: CartProps) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    const nextTotal = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    setTotal(nextTotal);
  }, [items]);

  return <p>Total: $${total.toFixed(2)}</p>;
}

Funciona, pero el componente se renderiza dos veces cuando cambia items. React primero renderiza con el total anterior, luego se ejecuta el efecto y actualiza total, y finalmente React vuelve a renderizar con el valor correcto. El componente también almacena información duplicada: total no es un estado independiente, está completamente determinado por items.

Después: calcular el valor durante el renderizado

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type CartProps = {
  items: CartItem[];
};

export function Cart({ items }: CartProps) {
  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return <p>Total: $${total.toFixed(2)}</p>;
}

Ya no hay estado adicional, efecto, segundo renderizado ni riesgo de que items y total se vuelvan incoherentes. El total siempre es correcto porque se calcula a partir de los items actuales durante el renderizado.

¿Y los cálculos costosos?

Si el cálculo es realmente costoso, memorízalo en lugar de sincronizarlo mediante estado.

import { useMemo } from "react";

export function Cart({ items }: CartProps) {
  const total = useMemo(() => {
    return items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }, [items]);

  return <p>Total: $${total.toFixed(2)}</p>;
}

No recurras automáticamente a useMemo. La mayoría de transformaciones de arrays son lo bastante rápidas para ejecutarse durante el renderizado. Utiliza memorización solo cuando el cálculo tenga un coste de rendimiento medible.

Regla de refactorización

Si un valor puede calcularse a partir de las props o del estado actuales, calcúlalo durante el renderizado. No lo almacenes en estado.

B-02

2. Transformar props

Otro patrón habitual consiste en recibir datos mediante props, transformarlos y guardar el resultado en estado. Pensemos en una lista de productos que solo debe mostrar los productos disponibles.

Antes: copiar props transformadas al estado

import { useEffect, useState } from "react";

type Product = {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
};

type ProductListProps = {
  products: Product[];
};

export function ProductList({ products }: ProductListProps) {
  const [availableProducts, setAvailableProducts] = useState<Product[]>([]);

  useEffect(() => {
    setAvailableProducts(
      products.filter((product) => product.inStock)
    );
  }, [products]);

  return (
    <ul>
      {availableProducts.map((product) => (
        <li key={product.id}>
          {product.name} — $${product.price}
        </li>
      ))}
    </ul>
  );
}

availableProducts no es un estado independiente. Es una versión transformada de products. Esto crea dos fuentes de verdad que deben mantenerse sincronizadas manualmente.

Después: transformar las props durante el renderizado

export function ProductList({ products }: ProductListProps) {
  const availableProducts = products.filter(
    (product) => product.inStock
  );

  return (
    <ul>
      {availableProducts.map((product) => (
        <li key={product.id}>
          {product.name} — $${product.price}
        </li>
      ))}
    </ul>
  );
}

El flujo de datos ahora es directo: products se filtra y después se renderiza la lista. No existe un paso de sincronización intermedio.

Filtrar y ordenar a la vez

El mismo principio se aplica cuando se requieren varias transformaciones.

Antes

const [visibleProducts, setVisibleProducts] = useState<Product[]>([]);

useEffect(() => {
  const result = products
    .filter((product) =>
      product.name.toLowerCase().includes(search.toLowerCase())
    )
    .sort((a, b) => a.price - b.price);

  setVisibleProducts(result);
}, [products, search]);

Después

const visibleProducts = products
  .filter((product) =>
    product.name.toLowerCase().includes(search.toLowerCase())
  )
  .sort((a, b) => a.price - b.price);

Ten cuidado con .sort(): modifica el array sobre el que se llama. Como .filter() devuelve un array nuevo, ordenar su resultado es seguro. Al ordenar props directamente, copia primero el array: const sortedProducts = [...products].sort((a, b) => a.price - b.price).

Para transformaciones costosas, memoriza el resultado con useMemo.

Regla de refactorización

No copies props al estado solo para filtrarlas, ordenarlas, mapearlas, formatearlas o agruparlas. Transforma las props durante el renderizado.

B-03

3. Lógica dirigida por eventos

Los efectos se ejecutan porque un componente se ha renderizado. Los manejadores de eventos se ejecutan porque ocurrió una interacción concreta del usuario. Esa distinción importa.

Supongamos que quieres mostrar una notificación después de que un usuario añada un producto al carrito.

Antes: reaccionar al estado de un evento mediante un efecto

import { useEffect, useState } from "react";

export function ProductActions() {
  const [addedProduct, setAddedProduct] = useState<string | null>(null);

  useEffect(() => {
    if (!addedProduct) {
      return;
    }

    console.log(`${addedProduct} se ha añadido al carrito`);
  }, [addedProduct]);

  function handleAddToCart(productName: string) {
    setAddedProduct(productName);
  }

  return (
    <button onClick={() => handleAddToCart("Teclado mecánico")}>
      Añadir al carrito
    </button>
  );
}

El código crea estado solo para activar un efecto. Pero la aplicación ya sabe exactamente cuándo se añadió el producto: dentro de handleAddToCart.

Después: mantener la lógica del evento en su manejador

export function ProductActions() {
  function handleAddToCart(productName: string) {
    console.log(`${productName} se ha añadido al carrito`);
  }

  return (
    <button onClick={() => handleAddToCart("Teclado mecánico")}>
      Añadir al carrito
    </button>
  );
}

La relación entre la acción y su consecuencia resulta ahora evidente.

Un ejemplo más realista

Imagina enviar un pedido y mostrar un toast.

Antes

const [orderSubmitted, setOrderSubmitted] = useState(false);

useEffect(() => {
  if (orderSubmitted) {
    toast.success("Tu pedido se ha enviado");
  }
}, [orderSubmitted]);

async function handleSubmit() {
  await submitOrder();
  setOrderSubmitted(true);
}

Después

async function handleSubmit() {
  await submitOrder();
  toast.success("Tu pedido se ha enviado");
}

La notificación pertenece al evento de envío. Utilizar un efecto crea aquí una indirección innecesaria: el usuario envía, cambia el estado, el componente se renderiza, el efecto detecta el estado y aparece la notificación. La versión directa es más simple: el usuario envía, se completa el pedido y aparece la notificación.

No traslades todas las funciones a un efecto

Un error habitual es pensar que los efectos secundarios, como las llamadas a una API, siempre deben ocurrir dentro de useEffect. No es cierto. Una llamada causada por el renderizado puede pertenecer a un efecto o a un framework de obtención de datos. Una llamada causada por una acción del usuario pertenece al manejador del evento.

async function handleDeleteAccount() {
  const confirmed = window.confirm(
    "¿Seguro que quieres eliminar tu cuenta?"
  );

  if (!confirmed) {
    return;
  }

  await deleteAccount();
  navigate("/goodbye");
}

La acción está dirigida por un evento, por lo que no necesita un efecto.

Regla de refactorización

Pregunta por qué debe ejecutarse la lógica. Si debe hacerlo porque el usuario hizo clic, envió, seleccionó, arrastró o escribió algo, colócala en el manejador correspondiente. No representes un evento como estado para después observarlo con un efecto.

B-04

4. Reiniciar estado

Reiniciar estado cuando cambia una prop es un motivo habitual para añadir efectos. Imagina un formulario de comentarios que debe vaciarse cada vez que cambia el artículo seleccionado.

Antes: reiniciar estado en un efecto

import { useEffect, useState } from "react";

type CommentFormProps = {
  articleId: string;
};

export function CommentForm({ articleId }: CommentFormProps) {
  const [comment, setComment] = useState("");

  useEffect(() => {
    setComment("");
  }, [articleId]);

  return (
    <textarea
      value={comment}
      onChange={(event) => setComment(event.target.value)}
    />
  );
}

Cuando cambia articleId, React primero renderiza el estado anterior del componente para el nuevo artículo. Después se ejecuta el efecto y borra el comentario. Eso crea un renderizado innecesario y asocia brevemente el comentario anterior con el nuevo artículo.

Después: reiniciar el componente mediante una key

type CommentSectionProps = {
  articleId: string;
};

export function CommentSection({ articleId }: CommentSectionProps) {
  return (
    <CommentForm
      key={articleId}
      articleId={articleId}
    />
  );
}

function CommentForm({ articleId }: CommentFormProps) {
  const [comment, setComment] = useState("");

  return (
    <form>
      <label htmlFor={`comment-${articleId}`}>Comentario</label>

      <textarea
        id={`comment-${articleId}`}
        value={comment}
        onChange={(event) => setComment(event.target.value)}
      />
    </form>
  );
}

Una key diferente indica a React que se trata de otra instancia del componente. Cuando cambia articleId, React elimina el formulario anterior y crea uno nuevo con estado nuevo. Suele ser la forma más limpia de reiniciar todo el estado de un subárbol.

Reiniciar solo una parte del estado

A veces no quieres reiniciar el componente completo. Imagina una página de producto donde la imagen seleccionada debe reiniciarse cuando cambia el producto, pero la preferencia de zoom debe mantenerse. Puedes mover el estado específico del producto a un hijo con key.

type ProductPageProps = {
  product: Product;
};

export function ProductPage({ product }: ProductPageProps) {
  const [zoomEnabled, setZoomEnabled] = useState(false);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={zoomEnabled}
          onChange={(event) => setZoomEnabled(event.target.checked)}
        />
        Activar zoom
      </label>

      <ProductGallery
        key={product.id}
        product={product}
        zoomEnabled={zoomEnabled}
      />
    </>
  );
}

Ahora ProductGallery se reinicia cuando cambia el producto, pero zoomEnabled permanece porque pertenece al padre. La estructura de componentes comunica qué estado pertenece a cada identidad.

Otra opción: almacenar identificadores estables

A veces se almacena un objeto seleccionado y después se reinicia cuando cambia la colección.

Antes

const [selectedProduct, setSelectedProduct] =
  useState<Product | null>(null);

useEffect(() => {
  setSelectedProduct(null);
}, [products]);

Un mejor diseño puede almacenar el ID seleccionado en lugar del objeto completo.

const [selectedProductId, setSelectedProductId] =
  useState<string | null>(null);

const selectedProduct =
  products.find((product) => product.id === selectedProductId) ?? null;

Si el producto seleccionado desaparece de la colección, selectedProduct se convierte naturalmente en null. No hay que mantener ningún efecto de reinicio.

Regla de refactorización

Cuando el estado debe reiniciarse porque cambió la identidad de algo, considera dar al componente otra key, mover el estado a un hijo con key o almacenar un identificador estable en vez de duplicar un objeto completo.

B-05

5. Sincronizar estado de React con estado de React

Una de las señales de alerta más claras en un componente React es un efecto cuyo único propósito consiste en actualizar una variable de estado cuando cambia otra.

Antes: sincronizar dos partes de estado

import { useEffect, useState } from "react";

export function RegistrationForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`.trim());
  }, [firstName, lastName]);

  return (
    <form>
      <input
        value={firstName}
        onChange={(event) => setFirstName(event.target.value)}
        placeholder="Nombre"
      />

      <input
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
        placeholder="Apellidos"
      />

      <p>Bienvenido, {fullName}</p>
    </form>
  );
}

fullName está sincronizado con firstName y lastName, pero no necesita ser estado.

Después: calcular el valor sincronizado

export function RegistrationForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = `${firstName} ${lastName}`.trim();

  return (
    <form>
      <input
        value={firstName}
        onChange={(event) => setFirstName(event.target.value)}
        placeholder="Nombre"
      />

      <input
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
        placeholder="Apellidos"
      />

      <p>Bienvenido, {fullName}</p>
    </form>
  );
}

Ahora existe una única fuente de verdad.

Mantener filtros sincronizados

Supongamos que un dashboard tiene un país y una ciudad seleccionados. Cuando cambia el país, debe borrarse la ciudad.

Antes: observar una variable de estado para actualizar otra

const [country, setCountry] = useState("");
const [city, setCity] = useState("");

useEffect(() => {
  setCity("");
}, [country]);

Es mejor gestionarlo donde cambia el país.

Después: actualizar el estado relacionado en el mismo evento

const [country, setCountry] = useState("");
const [city, setCity] = useState("");

function handleCountryChange(nextCountry: string) {
  setCountry(nextCountry);
  setCity("");
}

Ambas actualizaciones pertenecen a la misma interacción del usuario, por lo que mantenerlas juntas hace explícito el comportamiento.

Considerar la combinación de estado relacionado

Cuando varias variables de estado siempre cambian juntas, un reducer o un único objeto de estado pueden expresar mejor la relación.

type LocationState = {
  country: string;
  city: string;
};

const [location, setLocation] = useState<LocationState>({
  country: "",
  city: "",
});

function handleCountryChange(country: string) {
  setLocation({
    country,
    city: "",
  });
}

function handleCityChange(city: string) {
  setLocation((current) => ({
    ...current,
    city,
  }));
}

Para transiciones más complejas, utiliza un reducer.

type LocationState = {
  country: string;
  city: string;
};

type LocationAction =
  | { type: "countryChanged"; country: string }
  | { type: "cityChanged"; city: string };

function locationReducer(
  state: LocationState,
  action: LocationAction
): LocationState {
  switch (action.type) {
    case "countryChanged":
      return {
        country: action.country,
        city: "",
      };

    case "cityChanged":
      return {
        ...state,
        city: action.city,
      };

    default:
      return state;
  }
}

Esto hace explícitas las transiciones de estado válidas en lugar de depender de efectos que reparan el estado después de renderizar.

Regla de refactorización

No utilices efectos para que el estado de React alcance a otro estado de React. Deriva el valor durante el renderizado, actualiza el estado relacionado en el mismo evento, combina el estado que siempre cambia junto o utiliza un reducer para transiciones complejas.

B-06

Por qué causan problemas los efectos innecesarios

Los efectos innecesarios no son solo una preferencia de estilo. Pueden crear errores reales.

1. Crean ciclos de renderizado adicionales

Un efecto no puede actualizar el estado hasta después de que React renderice. La secuencia se convierte en: renderizar con un valor obsoleto, ejecutar el efecto, actualizar el estado y volver a renderizar. Cuando el valor podía calcularse durante el renderizado, el primer renderizado era innecesario.

2. Crean estados temporalmente incoherentes

Supongamos que cambia un producto, pero la imagen seleccionada se reinicia dentro de un efecto. Durante un renderizado, el componente puede combinar el producto nuevo con la imagen seleccionada anterior. Aunque el usuario nunca perciba el estado intermedio, el componente debe gestionarlo correctamente.

3. Duplican las fuentes de verdad

Si fullName es estado y también está determinado por firstName y lastName, ¿cuál es la fuente autoritativa? Cada valor duplicado introduce una responsabilidad de sincronización.

4. Ocultan la causalidad

Este código es directo: handleSave llama a saveDocument y después a showSuccessMessage. Este código dificulta seguir la relación: handleSave establece shouldSave en true y un efecto separado observa shouldSave para llamar a saveDocument y showSuccessMessage. La primera versión explica qué ocurre cuando el usuario guarda. La segunda exige seguir cambios de estado por distintas partes del componente.

5. Dificultan la gestión de dependencias

Una vez que la lógica está dentro de un efecto, debes gestionar cada dependencia reactiva que lee. Omitir una dependencia puede producir valores obsoletos. Añadirla puede hacer que el efecto se ejecute más veces de lo esperado. Intentar silenciar el linter suele ocultar el problema de diseño subyacente en lugar de resolverlo.

B-07

Cuándo necesitas realmente useEffect

La conclusión no es que useEffect sea malo. Es esencial cuando un componente debe sincronizarse con algo externo a React.

API del navegador

useEffect(() => {
  document.title = `${unreadCount} mensajes sin leer`;
}, [unreadCount]);

Suscripciones a eventos

useEffect(() => {
  function handleOnline() {
    setIsOnline(true);
  }

  function handleOffline() {
    setIsOnline(false);
  }

  window.addEventListener("online", handleOnline);
  window.addEventListener("offline", handleOffline);

  return () => {
    window.removeEventListener("online", handleOnline);
    window.removeEventListener("offline", handleOffline);
  };
}, []);

Widgets de terceros

useEffect(() => {
  const map = createMapWidget(containerRef.current);

  return () => {
    map.destroy();
  };
}, []);

Conexiones de red o en tiempo real

useEffect(() => {
  const connection = createChatConnection(roomId);

  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

Estos efectos sincronizan el componente con sistemas cuyo ciclo de vida existe fuera de React. Para eso se diseñaron los efectos.

Para los datos del servidor, considera utilizar el mecanismo de carga proporcionado por tu framework o una biblioteca dedicada al estado del servidor. Obtener datos directamente en efectos puede ser válido, pero exige gestionar con cuidado cancelación, condiciones de carrera, caché, estados de carga y solicitudes duplicadas.

B-08

Una lista práctica de refactorización

Antes de escribir un efecto, hazte estas preguntas.

¿Puedo calcular este valor durante el renderizado?

Si la respuesta es sí, elimina el estado y calcula directamente el valor: const fullName = `${firstName} ${lastName}`.

¿Estoy transformando props?

Filtra, mapea, ordena, agrupa o formatea durante el renderizado: const visibleItems = items.filter(matchesSearch).

¿Una acción concreta del usuario causó esta lógica?

Coloca la lógica en el manejador del evento.

function handleSubmit() {
  submitForm();
  showSuccessToast();
}

¿Debe reiniciarse el estado cuando cambia una entidad?

Utiliza una key, mueve el estado a un hijo con key o almacena un identificador.

<Editor key={documentId} documentId={documentId} />

¿Estoy actualizando una variable de estado porque cambió otra?

Deriva el valor, actualiza ambas en el mismo evento o rediseña el modelo de estado.

function handleCountryChange(country: string) {
  setCountry(country);
  setCity("");
}

¿Estoy sincronizando con algo externo a React?

Aquí es donde probablemente corresponda un efecto. Algunos ejemplos son las API del navegador, conexiones WebSocket, integraciones con el DOM, temporizadores, analíticas, bibliotecas de terceros y suscripciones externas.

B-09

El modelo mental que lo cambia todo

No preguntes cómo hacer que funcione este efecto. Pregunta por qué debe ocurrir esta lógica.

Hay tres respuestas habituales:

  • Porque el componente se ha renderizado: calcula el valor durante el renderizado.
  • Porque el usuario hizo algo: ejecuta la lógica en un manejador de eventos.
  • Porque un sistema externo debe mantenerse sincronizado: utiliza un efecto.

Esta distinción elimina una cantidad sorprendente de efectos.

B-10

Conclusión

useEffect no debe ser el pegamento que mantiene unido el estado de React. Cuando un estado de React necesita sincronizarse con otro, el verdadero problema suele estar en el modelo de datos del componente.

Prefiere calcular en lugar de sincronizar, manejadores de eventos en lugar de efectos que observan eventos, keys en lugar de reinicios manuales, fuentes únicas de verdad en lugar de estado duplicado y reducers en lugar de lógica que repara el estado.

Los efectos son una vía de escape para interactuar con sistemas externos a React. Cuantos menos efectos innecesarios contengan tus componentes, más predecible será la aplicación.

Antes de añadir tu próximo useEffect, detente y pregunta: ¿React se está sincronizando con un sistema externo o estoy utilizando un efecto para compensar un estado que no necesitaba?

B-11

Dónde encaja

Reconocer efectos innecesarios y sustituirlos por patrones más simples forma parte de construir código React que los equipos pueden asumir y evolucionar con confianza. La disciplina de refactorización de este artículo reduce renderizados adicionales, simplifica el flujo de datos y facilita razonar sobre el comportamiento de los componentes, resultados relevantes en cualquier servicio.

Ver cómo las bases dan forma a la entrega