Auditoría de useEffect: Diez errores comunes, sus síntomas y soluciones reales
Identifique los diez errores más comunes de useEffect en React, desde la falta de limpieza y cierres obsoletos hasta condiciones de carrera y sincronización de propiedades, y aplique la solución adecuada.
Fuentes: 10 React useEffect Mistakes That Are Secretly Breaking Your App in 2026
React 19, el Compilador de React y los Componentes Servidor han trasladado gran parte del trabajo fuera de useEffect, pero este hook sigue siendo donde residen muchos de los bugs más difíciles en React: fugas de memoria, datos obsoletos, parpadeos y bucles de renderizado. La mayoría provienen de diez errores recurrentes. A continuación, cada uno incluye la versión defectuosa, el síntoma y una solución, seguida de una lista de verificación para auditar tus propios efectos.
El principio detrás de cada solución: useEffect existe para sincronizar un componente con algo fuera de React, como un temporizador, un socket o una API del navegador.
1. Olvidar la función de limpieza
Los temporizadores, las suscripciones y los listeners de eventos creados en un efecto siguen funcionando después de que el componente se vuelva a renderizar o se desmonte, a menos que los elimines. En este caso, el intervalo nunca se cancela:
// ❌ Broken: No cleanup
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// Missing: return () => clearInterval(timer);
}, []);
Los síntomas son intervalos fugados y listeners duplicados. Durante el desarrollo, el Modo Estricto monta, desmonta y vuelve a montar deliberadamente los componentes para evidenciar esto: sin una limpieza adecuada se generan dos intervalos y el contador avanza dos veces por segundo. En producción, la fuga aparece cada vez que se vuelve a montar el componente. Devuelva una función que anule la configuración anterior:
// ✅ Fixed: Always clean up
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
2. Uso de un array de dependencias vacío para indicar “ejecutar una vez”
Un array vacío no significa “al montar” en ningún sentido especial. Le indica a React que el efecto no depende de nada, por lo que nunca se vuelve a ejecutar. Si el efecto lee props o estado, esos valores permanecen fijos en los valores de la primera renderización:
// ❌ Broken: Stale closure
function ChatRoom({ roomId }) {
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, []); // roomId changes, but effect never re-runs
}
Cuando roomId cambia, el componente permanece en la sala original. Enumere todos los valores reactivos que utiliza el efecto; la regla de linting react-hooks/exhaustive-deps detecta esto.
// ✅ Fixed: Include real dependencies
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, [roomId]);
Ahora React se desconecta de la sala anterior y se conecta a la nueva cada vez que roomId cambia.
3. Depender de objetos que se recrean en cada renderizado
Las dependencias se comparan por referencia. Un objeto o literal de array creado durante el renderizado representa una nueva referencia en cada ocasión, por lo que un efecto que dependa de él se vuelve a ejecutar después de cada renderizado:
// ❌ Broken: New object every render
function Parent() {
const config = { timeout: 5000 }; // New reference every time
useEffect(() => {
fetchData(config);
}, [config]); // Runs every single render
}
Estabilice la referencia. Si el valor nunca cambia, defínalo una sola vez fuera del componente. Si se construye a partir de props o estado, envuélvalo en useMemo con las dependencias adecuadas. El fragmento a continuación muestra ambas opciones una al lado de la otra; en la práctica debería elegir una:
// ✅ Fixed: Stable reference
const CONFIG = { timeout: 5000 }; // Defined outside component
function Parent() {
const config = useMemo(() => ({ timeout: 5000 }), []);
useEffect(() => {
fetchData(config);
}, [config]);
}
A menudo es aún más sencillo: dependa de primitivas (timeout en lugar de config) y construya el objeto dentro del efecto. El Compilador de React memoriza muchos valores en el código que compila, pero no los objetos provenientes de código o bibliotecas no compiladas, por lo que no confíe en él para garantizar la corrección de los efectos.
4. Permitir que las respuestas asíncronas compitan entre sí
Cuando una dependencia cambia rápidamente, como en un cuadro de búsqueda o al cambiar tabulaciones con rapidez, varios solicitudes pueden estar en proceso al mismo tiempo, y no necesariamente se resuelven en orden:
// ❌ Broken: Race condition
useEffect(() => {
fetch(`/api/search?q=${query}`).then(res => res.json()).then(setResults);
}, [query]);
Si un usuario escribe “react” y luego “react hooks”, la respuesta para “react” podría llegar por último y sobrescribir los resultados correctos. La solución es cancelar la solicitud obsoleta en el proceso de limpieza. AbortController cancela la solicitud, y el error resultante AbortError se ignora para que no se considere un fallo real:
// ✅ Fixed: Abort in-flight requests
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);
En particular, en las interfaces de búsqueda existen matices adicionales, abordados en arreglar condiciones de carrera que el retardo no puede resolver.
5. Almacenar valores derivados en el estado
Si un valor puede calcularse a partir de props o estado existentes, no necesita su propio estado ni un efecto:
// ❌ Broken: Redundant state + effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Cada cambio se renderiza dos veces, primero con un fullName obsoleto, lo que genera dos fuentes de verdad diferentes. Calcule el valor durante el renderizado, utilizando useMemo únicamente para operaciones realmente costosas:
// ✅ Fixed: Compute during render
const fullName = `${firstName} ${lastName}`;// Or for expensive computations
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
Tenga en cuenta que el fragmento muestra dos alternativas; declarar fullName dos veces en el mismo ámbito es un error de sintaxis, por lo que mantenga solo una. El patrón general se explora en una alternativa más segura para sincronizar el estado con useEffect.
6. Creación de un bucle de actualización
Un efecto que establece el estado también depende de sí mismo para programar sus ejecuciones indefinidamente:
// ❌ Broken: Infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);
La actualización desencadena un renderizado, el renderizado cambia count, el valor modificado de count vuelve a ejecutar el efecto, y así sucesivamente. React finalmente lo detiene con un error de “Se superó la profundidad máxima de actualización”. Una actualización funcional elimina la necesidad de leer count dentro del efecto, por lo que ya no es necesario que sea una dependencia:
// ✅ Fixed: Functional update + correct dependencies
useEffect(() => {
const timer = setTimeout(() => {
setCount(c => c + 1); // Functional update avoids dependency
}, 1000);
return () => clearTimeout(timer);
}, []); // No count dependency needed
Tenga en cuenta que esto cambia el comportamiento: el contador ahora aumenta una vez, un segundo después del montaje. Para un contador que avanza continuamente, utilice setInterval con función de limpieza como en el error 1. Cualquier setState dentro de un efecto necesita una condición que garantice su finalización.
7. Omitir completamente el array de dependencias
Sin un segundo argumento, un efecto se ejecuta después de cada renderizado. A veces eso es lo que se desea, pero generalmente es accidental y costoso:
// ❌ Broken: Runs on every render
useEffect(() => {
console.log('Effect running');
performHeavyComputation(data);
});
El compilador de React reduce las actualizaciones innecesarias, pero no hace que el trabajo dentro de tus efectos sea más económico en recursos, por lo que un efecto sin protección que realice tareas intensivas sigue siendo un cuello de botella. Declara la dependencia real:
// ✅ Fixed: Explicit dependencies
useEffect(() => {
performHeavyComputation(data);
}, [data]);
8. Manejo de eventos de usuario en efectos
Los efectos se ejecutan porque algo se ha renderizado, no porque un usuario haya hecho algo. Las analíticas vinculadas a una bandera de estado se activan cada vez que esa bandera cambia, sea por cualquier motivo:
// ❌ Broken: Event logic in effect
useEffect(() => {
if (isModalOpen) {
trackEvent('modal_opened');
}
}, [isModalOpen]);
Este evento se registra al montar si el modal comienza abierto, cuando un padre cambia la propiedad, o cuando se restaura el estado, y no solo cuando el usuario hace clic. Coloca la lógica de interacción en el manejador que realiza dicha interacción:
// ✅ Fixed: Event handler
function handleOpenModal() {
setIsModalOpen(true);
trackEvent('modal_opened');
}
Si puedes nombrar la acción del usuario que debe activar el código, esta pertenece a un manejador.
9. Obtención de datos con un efecto simple
Con los componentes de servidor y los cargadores de framework que se encargan de los datos iniciales en muchas aplicaciones, la obtención de datos desde el lado del cliente mediante efectos es menos necesaria. Cuando se realiza una solicitud desde el cliente, un efecto sin procesamiento adicional es frágil:
// ❌ Broken: Raw fetch in effect
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
No cuenta con estado de carga, manejo de errores, deduplicación de solicitudes, caché y, como muestra el error 4, tampoco protección contra concurrencias. Una biblioteca para la obtención de datos se encarga de todo eso:
// ✅ Fixed: Use a data-fetching library
const { data: user, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000,
});
TanStack Query y SWR son las opciones habituales. Sin ellas, se debe crear un gancho personalizado que maneje la carga, los errores y las interrupciones en lugar de efectos de solicitud puntuales.
10. Copiar propiedades al estado y sincronizarlas
Un componente que recibe un valor inicial, lo copia al estado y luego utiliza un efecto para reflejar los cambios en las propiedades, está actuando en contra de sí mismo:
// ❌ Broken: Prop-to-state sync
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
useEffect(() => {
setEmail(initialEmail); // Sync prop to state
}, [initialEmail]);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
Cada vez que el padre proporciona un nuevo initialEmail, lo escrito por el usuario se sobrescribe silenciosamente, tras una renderización adicional con el valor antiguo. Elija un enfoque: haga que el componente sea completamente controlado, o manténgalo sin control y reinícielo deliberadamente cambiando su key, lo que le da a React una instancia nueva con estado actualizado:
// ✅ Fixed: Controlled or fully uncontrolled
// Option 1: Fully controlled — no internal state
function EmailInput({ email, onChange }) {
return <input value={email} onChange={onChange} />;
}
// Option 2: Fully uncontrolled with key
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Usage: Reset by changing key
<EmailInput key={userId} initialEmail={user.email} />
Cómo React 19 cambia la situación
Las versiones recientes de React hacen que estos errores sean tanto más visibles como menos necesarios:
- El montaje doble exclusivo para desarrollo de Modo Estricto revela tempranamente las tareas de limpieza faltantes o incorrectas.
- El Compilador de React memoriza automáticamente los componentes y muchos valores, lo que reduce las renderizaciones accidentales pero no corrige la lógica de los efectos.
use puede leer promesas y contexto durante el renderizado, eliminando algunas de las razones para utilizar efectos con valores asíncronos.Lista de verificación previa a la fusión para efectos
Antes de publicar un efecto, verifica:
- Cada suscripción, temporizador y oyente cuenta con una función de limpieza correspondiente.
- La matriz de dependencias enumera todos los valores reactivos que lee el efecto.
- Las dependencias de objetos y arrays son estables, o se reemplazan por dependencias primitivas.
- El trabajo asíncrono se cancela o se ignora cuando cambian las dependencias.
- Nada de lo calculado en el efecto podría obtenerse simplemente durante el renderizado.
- Ninguna actualización de estado puede volver a activar el mismo efecto sin una condición de detención.
- La lógica activada por el usuario se encuentra en los manejadores de eventos.
- La obtención de datos utiliza un componente del servidor, un cargador de frameworks o una biblioteca para obtener datos cuando es posible.
- Cada elemento de estado tiene exactamente una fuente de verdad.
Conclusión
Casi todos los errores en useEffect provienen de usar este hook para algo distinto a la sincronización externa. Los valores derivados deben ir en la función de renderizado, las acciones del usuario en los manejadores, los datos del servidor en una capa de datos, y el estado reconfigurable detrás de un key. Las suscripciones reales que quedan necesitan dependencias precisas y un proceso de limpieza adecuado. A menudo, el mejor resultado de una auditoría es eliminar un efecto no necesario.
Lecturas relacionadas
- Seis errores de timing asincrónico que se manifiestan como problemas en el rendering de React — Aprenda a identificar seis patrones asincrónicos en JavaScript, desde valores capturados obsoletos hasta ausencia de devoluciones de Promise, que hacen que los componentes de React muestren un estado incorrecto o imposible.
- Construya un toy useState para entender snapshots, colas y effects — Aprenda cómo se comportan los hooks de React al crear un pequeño useState, y luego utilice ese modelo para explicar las colas de actualización, snapshots obsoletos, cierres, limpieza de effects, refs y agrupamiento.