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