¿Por qué useEffect se ejecuta dos veces? Guía Completa del Strict Mode en React 19
Acabas de crear un proyecto React nuevo, escribes un useEffect simple para obtener datos y... la petición se hizo dos veces. El console.log también salió duplicado. Todo ejecutándose dos veces.
"¿Es un bug? ¿Configuré algo mal? ¿Tengo que hacer downgrade de React?"
Tranquilo. Tu código no está roto. Es así por diseño. React lo hace a propósito. Y entender por qué existe este comportamiento te hará significativamente mejor desarrollador React. Más importante aún, aprender a escribir Effects que funcionen correctamente bajo este comportamiento evitará bugs reales en producción que de otra forma pasarían desapercibidos.
En esta guía vamos a explorar exactamente qué está pasando, por qué el equipo de React tomó esta decisión aparentemente molesta, y lo más importante: cómo escribir Effects que sean resistentes, correctos y listos para producción.
¿Qué está pasando?
Digamos que escribiste esto:
import { useEffect, useState } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { console.log('Obteniendo datos...', userId); fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => setUser(data)); }, [userId]); return <div>{user?.name}</div>; }
En modo desarrollo, la consola muestra:
Obteniendo datos... 123
Obteniendo datos... 123
Y en la pestaña Network hay dos peticiones idénticas a /api/users/123. ¿Raro, verdad?
Si llevas tiempo con React, esto puede parecerte extraño. Antes de React 18, los Effects se ejecutaban una sola vez en desarrollo—igual que en producción. ¿Qué cambió?
El culpable: React.StrictMode
La razón es StrictMode. Normalmente está así en tu main.jsx o index.js:
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')).render( <StrictMode> <App /> </StrictMode> );
Desde React 18, StrictMode hace esto a propósito:
- Monta el componente
- Lo desmonta inmediatamente
- Lo vuelve a montar
Solo pasa en desarrollo. En el build de producción, ejecuta una sola vez.
El punto clave: esta doble invocación intencional está diseñada para exponer bugs en tu código.
¿Por qué hacerlo a propósito? Entendiendo las razones
El equipo de React no te está trolleando. Lo hicieron por una verdad fundamental sobre los Effects:
"Si se rompe ejecutándose dos veces, también se romperá en producción. Simplemente no lo habrías detectado durante el desarrollo."
Piensa en escenarios reales donde los Effects pueden ejecutarse múltiples veces:
Escenario 1: Fast Refresh Durante Desarrollo
Estás programando, guardas un archivo, y Fast Refresh re-renderiza tu componente. Si tu Effect configura una suscripción pero no la limpia correctamente, ahora tienes suscripciones duplicadas.
Escenario 2: React Suspense y Transiciones
Con características de React 18+ como startTransition y Suspense, React puede necesitar "suspender" un render, luego reintentarlo. Los componentes pueden montarse, desmontarse y remontarse como parte del comportamiento normal de renderizado concurrente.
Escenario 3: Remontaje en Navegación Real
El usuario navega a otra página, presiona el botón atrás, el componente se remonta. Si tu Effect crea una conexión WebSocket sin limpieza, ahora tienes conexiones huérfanas.
Escenario 4: Funcionalidades Futuras
El equipo de React está preparando APIs "Offscreen" (renderizar componentes en background antes de que sean visibles). Estas requerirán que los componentes se monten/desmonten múltiples veces como parte de su ciclo de vida normal.
La doble invocación en Strict Mode simula estas condiciones reales. Si tu código sobrevive esta prueba de estrés, es mucho más probable que funcione correctamente en todos estos escenarios.
El problema real: Effects sin cleanup
Diagnostiquemos dónde la mayoría de desarrolladores se equivocan:
useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; }, []);
¿Qué está mal? No hay función de limpieza. Cuando Strict Mode desmonta y remonta, creas una segunda conexión WebSocket. La primera queda huérfana—aún recibiendo mensajes, consumiendo memoria, pero sin referencia para cerrarla.
El patrón correcto:
useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; // 👇 Esto es clave. Se llama al desmontar return () => { socket.close(); }; }, []);
Ahora el flujo es:
- Montar → Crear WebSocket #1
- Strict Mode desmonta → Cerrar WebSocket #1
- Remontar → Crear WebSocket #2
Solo existe una conexión a la vez. Tu Effect es resistente.
Arreglando patrones comunes de Effects
Veamos los patrones más comunes y cómo hacerlos a prueba del Strict Mode.
Patrón 1: Obtención de datos con AbortController
Enfoque problemático:
// ❌ Problemático: Doble fetch, condiciones de carrera useEffect(() => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(setUser); }, [userId]);
Enfoque resistente:
// ✅ Correcto: Aborta solicitudes en vuelo en cleanup useEffect(() => { const controller = new AbortController(); fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(res => res.json()) .then(setUser) .catch(err => { if (err.name !== 'AbortError') { console.error('Fetch falló:', err); } }); return () => controller.abort(); }, [userId]);
Qué logra esto:
- Cuando Strict Mode desmonta, el cleanup aborta la primera petición
- La segunda petición procede normalmente
- En navegación rápida, peticiones antiguas no sobrescriben datos nuevos (problema de condición de carrera)
Patrón 2: Event listeners en window/document
// ❌ Problemático: Los listeners se acumulan useEffect(() => { window.addEventListener('resize', handleResize); }, []);
// ✅ Correcto: Eliminación limpia useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);
Patrón 3: Timers e intervalos
// ❌ Problemático: Múltiples intervalos ejecutándose useEffect(() => { setInterval(() => { setCount(c => c + 1); }, 1000); }, []);
// ✅ Correcto: Limpiar el intervalo useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []);
Patrón 4: Inicialización de librerías de terceros
Las librerías de gráficos, frameworks de animación o herramientas de manipulación DOM necesitan inicialización y destrucción:
// ❌ Problemático: El chart se inicializa dos veces useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); }, [chartData]);
// ✅ Correcto: Destruir en cleanup useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); return () => chart.destroy(); }, [chartData]);
Patrón 5: Analytics y tracking
Esto es un poco matizado. Probablemente no quieres deduplicar tu analytics:
// Esto dispara dos veces en Strict Mode - ¿es problema? useEffect(() => { analytics.track('page_viewed', { page: pathname }); }, [pathname]);
La respuesta: Depende de tu servicio de analytics. La mayoría de analytics modernos (GA4, Amplitude, Mixpanel) están diseñados para manejar eventos duplicados o hacer debounce del lado del servidor. El doble disparo en desarrollo no debería causar problemas en datos de producción.
Si de verdad importa para tu caso, puedes usar un ref para trackear si ya se hizo el tracking:
const hasTracked = useRef(false); useEffect(() => { if (!hasTracked.current) { analytics.track('page_viewed', { page: pathname }); hasTracked.current = true; } }, [pathname]);
Pero cuidado: este patrón puede ocultar bugs legítimos. Úsalo con moderación.
El anti-patrón de la bandera booleana
Algunos desarrolladores, al encontrar Strict Mode por primera vez, recurren a esta "solución":
// ⚠️ Anti-patrón: No hagas esto useEffect(() => { let ignore = false; const run = async () => { const data = await fetchData(); if (!ignore) { setData(data); } }; run(); return () => { ignore = true }; }, []);
Espera—¡este patrón es realmente correcto para fetching de datos async! La bandera ignore previene establecer state después del desmontaje. Pero a veces los desarrolladores lo aplican mal:
// ❌ Incorrecto: Esto anula el propósito de Strict Mode const hasRun = useRef(false); useEffect(() => { if (hasRun.current) return; hasRun.current = true; // Lógica del Effect... }, []);
Este patrón dice "solo ejecuta una vez, incluso en Strict Mode." ¿Por qué es problemático?
- Oculta bugs. Si tu Effect necesita cleanup pero no lo tiene, este patrón enmascara el problema durante el desarrollo.
- Se rompe en escenarios de producción. ¿Qué pasa si tu componente legítimamente se remonta? El Effect no volverá a ejecutarse.
- Viola las expectativas de React. Los Effects deben ser resistentes a múltiples invocaciones por diseño.
Conclusión: Si te encuentras buscando patrones de "ejecutar solo una vez", pregúntate: ¿por qué mi Effect se rompe cuando se ejecuta dos veces? La respuesta usualmente revela una función de cleanup que olvidaste escribir.
Nuevos patrones de React 19: Server Actions y el hook use
React 19 introduce patrones que evitan completamente algunas de estas trampas de Effects. Conocerlos puede modernizar tu enfoque de fetching de datos.
El hook use para obtención de datos
React 19 introduce el hook use para consumir promises y context:
import { use, Suspense } from 'react'; function UserProfile({ userPromise }) { // `use` desenvuelve la promise const user = use(userPromise); return <div>{user.name}</div>; } // Componente padre function App({ userId }) { const [userPromise] = useState(() => fetchUser(userId)); return ( <Suspense fallback={<div>Cargando...</div>}> <UserProfile userPromise={userPromise} /> </Suspense> ); }
Por qué importa: La promise se crea una vez en el padre y se pasa hacia abajo. El componente hijo no gestiona el ciclo de vida del fetching—simplemente lee el resultado. Esto evita completamente el problema del "doble fetch" porque no hay Effect involucrado.
Server Actions para mutaciones
Para mutaciones de datos, los Server Actions de React 19 proveen un modelo diferente:
// actions.js - ejecuta en el servidor 'use server'; export async function createUser(formData) { const user = await db.users.create({ name: formData.get('name'), email: formData.get('email'), }); return user; }
// Componente - sin useEffect necesario para submit import { createUser } from './actions'; function CreateUserForm() { return ( <form action={createUser}> <input name="name" /> <input name="email" /> <button type="submit">Crear</button> </form> ); }
Los Server Actions se invocan como form actions, eliminando la necesidad de handlers de submit y Effects. La acción corre en el servidor, y React maneja la actualización del UI.
Cuándo todavía necesitas useEffect
Estos nuevos patrones son poderosos, pero useEffect no va a desaparecer. Todavía lo necesitas para:
- Mediciones de DOM (leer dimensiones de elementos)
- Suscripciones a APIs del navegador (IntersectionObserver, ResizeObserver)
- Integración con librerías de terceros
- Animaciones disparadas por cambios de state
- Conexiones WebSocket
- Gestión de timers/intervalos
Para estos casos, el patrón de función de cleanup sigue siendo esencial.
Estrategia de debugging: ¿Es Strict Mode o un bug real?
Cuando veas dobles invocaciones, aquí un enfoque sistemático de debugging:
Paso 1: Confirmar que Strict Mode está activo
Revisa tu punto de entrada (index.js, main.jsx, main.tsx):
<StrictMode> <App /> </StrictMode>
Si StrictMode no está ahí, las dobles invocaciones indican un bug real—probablemente en tu lógica de routing o componente padre.
Paso 2: Añadir logging para entender el flujo
useEffect(() => { console.log('Effect SETUP para:', userId); return () => { console.log('Effect CLEANUP para:', userId); }; }, [userId]);
En Strict Mode verás:
Effect SETUP para: 123
Effect CLEANUP para: 123
Effect SETUP para: 123
Esto confirma el ciclo mount → unmount → remount.
Paso 3: Probar en modo producción
Ejecuta un build de producción localmente:
npm run build npm run preview
En producción, Strict Mode está deshabilitado. Si la doble invocación persiste, tienes un bug real.
Paso 4: Revisar tu array de dependencias
Una causa común de re-ejecuciones inesperadas son dependencias inestables:
// ❌ Crea nuevo objeto cada render → Effect corre cada render useEffect(() => { // ... }, [{ some: 'object' }]); // ✅ Primitivo estable useEffect(() => { // ... }, [userId]);
Objetos, arrays y funciones definidos inline durante el render son nuevos en cada render, causando que los Effects se re-ejecuten.
Implicaciones de rendimiento: ¿Deberías preocuparte?
Una preocupación natural: "¿Esta doble ejecución no hará mi app más lenta?"
En desarrollo: Sí, marginalmente. Pero la velocidad de desarrollo inherentemente difiere de producción. Los beneficios de seguridad superan los milisegundos.
En producción: Strict Mode se elimina completamente. Cero impacto en rendimiento. Tu Effect corre exactamente una vez por mount/cambio de dependencia.
El equipo de React recuerda regularmente a los desarrolladores: no optimices para rendimiento de desarrollo. El build de dev incluye muchos checks, warnings y slowdowns intencionales que no existen en producción.
Condiciones de carrera: Escenarios intermedios
Considera un usuario cambiando rápidamente entre perfiles:
// Usuario hace clic rápido: Perfil A → Perfil B → Perfil C
Sin cleanup apropiado, podrías ver:
- Petición A inicia
- Petición B inicia
- Petición C inicia
- Petición A completa → state se pone en User A
- Petición C completa → state se pone en User C
- Petición B completa → state se pone en User B ← ¡Mal!
El usuario espera ver User C, pero User B fue el último en completar.
AbortController arregla esto: cuando el usuario hace clic en Perfil B, Petición A se aborta. Cuando hace clic en Perfil C, Petición B se aborta. Solo Petición C completa.
Effects que deben correr solo en mount
A veces genuinamente necesitas lógica que solo corra en mount y no se re-ejecute. Usa un ref con cuidado:
const initialized = useRef(false); useEffect(() => { // Ejecutar setup que solo debe pasar una vez initializeComplexLibrary(); if (!initialized.current) { initialized.current = true; // Setup de una sola vez como registrar la app con un backend registerAppInstance(); } return () => { // Pero siempre hacer cleanup cleanupComplexLibrary(); }; }, []);
Nota: el cleanup todavía corre cada vez. El ref solo protege la lógica de registro "de una sola vez".
Sincronizando con sistemas externos
Cuando tu Effect sincroniza state con algo fuera de React (DOM, API externa, storage del navegador):
useEffect(() => { // Leer state externo en mount const savedTheme = localStorage.getItem('theme'); if (savedTheme) setTheme(savedTheme); // No se necesita cleanup para lectura // Pero si configuras listeners, límpialos const handler = (e) => { if (e.key === 'theme') setTheme(e.newValue); }; window.addEventListener('storage', handler); return () => window.removeEventListener('storage', handler); }, []);
Mejores prácticas para Effects resistentes
Consolidemos todo en un conjunto de mejores prácticas accionables:
1. Siempre retorna una función de cleanup
Hazlo un hábito. Incluso si crees que no necesitas cleanup:
useEffect(() => { // Lógica de setup return () => { // Lógica de cleanup (incluso si es un comentario vacío) // Esto te fuerza a pensar qué necesita limpieza }; }, [deps]);
2. Usa AbortController para todas las llamadas fetch
Este patrón debería ser natural:
useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(/* ... */) .catch(err => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [url]);
3. Almacena referencias mutables en refs
Cuando los Effects necesitan state mutable que persista a través de ciclos de cleanup:
const socketRef = useRef(null); useEffect(() => { socketRef.current = new WebSocket(url); return () => socketRef.current?.close(); }, [url]);
4. Extrae custom hooks para patrones reutilizables
Centraliza el comportamiento correcto:
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); setLoading(true); fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(res.statusText); return res.json(); }) .then(setData) .catch(err => { if (err.name !== 'AbortError') setError(err); }) .finally(() => setLoading(false)); return () => controller.abort(); }, [url]); return { data, loading, error }; }
5. Considera React Query o SWR
Para fetching de datos complejo, estas librerías manejan caching, deduplicación y cleanup automáticamente:
// Usando React Query import { useQuery } from '@tanstack/react-query'; function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), }); if (isLoading) return <div>Cargando...</div>; if (error) return <div>Error ocurrido</div>; return <div>{user.name}</div>; }
React Query deduplica peticiones automáticamente—incluso con el doble mount de Strict Mode, verás solo una petición de red.
¿Deshabilitar Strict Mode: deberías?
Puedes eliminar StrictMode:
// Antes <StrictMode> <App /> </StrictMode> // Después <App />
¿Deberías? Casi nunca. He aquí por qué:
- Estás ocultando bugs, no arreglándolos. Esos bugs se manifestarán en producción.
- Pierdes preparación para el futuro. Las próximas características de React pueden depender de Effects resistentes.
- Es un code smell. Si necesitas deshabilitar Strict Mode, tus Effects probablemente tienen problemas estructurales.
La única razón legítima para deshabilitar temporalmente Strict Mode es cuando estás debuggeando para aislar si un problema es relacionado con Strict Mode o un bug separado.
Conclusión: Abraza la doble invocación
El Strict Mode de React 19 no trabaja en tu contra—te está entrenando para escribir mejor código. Cada vez que encuentres una doble invocación, pregúntate:
- ¿Mi Effect tiene una función de cleanup?
- ¿Mi función de cleanup realmente limpia lo que el Effect configuró?
- ¿Mi Effect funcionaría correctamente si se ejecutara 3 veces? ¿10 veces? ¿100 veces?
Si la respuesta a las tres es "sí", tu Effect está listo para producción.
Los patrones en esta guía—AbortController para fetches, funciones de cleanup para suscripciones, refs para state mutable—no son workarounds de Strict Mode. Son la forma correcta de escribir Effects, punto. Strict Mode solo hace que su importancia sea innegable.
La próxima vez que tu Effect se ejecute dos veces, no busques un workaround. Agradece a Strict Mode por atrapar un bug antes que tus usuarios, y escribe una función de cleanup. Tu yo del futuro—debuggeando un problema de producción a las 2 AM—te lo agradecerá.
Última actualización: Diciembre 2025