El planificador de React y el bucle de eventos: ¿quién decide realmente cuándo se ejecuta el trabajo?
Vea cómo funciona el planificador cooperativo de React dentro del bucle de eventos de JavaScript, por qué las transiciones pueden ceder el control y por qué ningún planificador puede rescatar un hilo bloqueado.
Fuentes: How Does React’s Scheduler Work With the JavaScript Event Loop?
React decide no solo qué debe cambiar en la pantalla, sino también cuándo se realiza esa tarea, utilizando prioridades, canales y renderizado interrumpible. Sin embargo, React es solo JavaScript, y JavaScript ya cuenta con un bucle de eventos que determina cuándo se ejecuta el código. Entonces, ¿por qué React incluye su propio programador de tareas, y cuál de los dos es el que realmente está a cargo? Este artículo desentraña esa relación, muestra dónde encajan las características concurrentes como las transiciones y useDeferredValue, y explica el límite infranqueable que ninguna estrategia de programación inteligente puede superar, tanto en la web como en React Native.
Comience con la pila de llamadas, no con React
JavaScript puro ejecuta el código en una pila de llamadas, un frame a la vez. En el fragmento siguiente, nada es asíncrono: un registro, una llamada a función que realiza un registro y otro registro final.
console.log("A");
function calculate() {
console.log("B");
}
calculate();
console.log("C");
La salida sigue exactamente el orden del código:
A
↓
B
↓
C
Ahora cambie la llamada a la función por un temporizador con una demora de cero:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
El orden cambia:
A
C
B
Esto sorprende a la gente porque la demora es literalmente:
0
La explicación es que setTimeout() nunca significa “ejecutar esto ahora”. Significa que la función de callback pasa a estar disponible para ejecutarse después de la demora, y aún debe esperar a que termine el código que se está ejecutando en ese momento para que el sistema operativo la ejecute. Decidir cuándo ocurre eso es tarea del bucle de eventos.
El bucle de eventos en resumen
Una imagen simplificada es suficiente para razonar sobre React:
┌─────────────┐
│ Call Stack │
└──────┬──────┘
│
↓
JavaScript Runs
│
↓
Stack becomes free
│
↓
Event Loop
│
┌──────┴──────┐
↓ ↓
Microtasks Tasks
El código se ejecuta en la pila hasta que esta queda vacía. Solo entonces el bucle de eventos procesa el trabajo en cola: primero se ejecutan las microtareas (como las devoluciones de llamada de las promesas resueltas) y luego se procesa la siguiente tarea (como una devolución de llamada de un temporizador o un evento de entrada). Los navegadores y entornos de ejecución reales añaden más detalles, incluidos los pasos de renderizado entre tareas, pero hay una regla que es más importante que todas las demás: el JavaScript síncrono que ya está en ejecución no puede ser interrumpido por otro JavaScript. Si el hilo está ocupado, todo lo demás espera. Consulte cierres y el bucle de eventos para obtener más información sobre las colas.
Una aplicación congelada intencionadamente
Para entender qué significa esa regla en React, considere un componente cuyo manejador de clics se queda en un bucle ocupado durante unos cinco segundos antes de actualizar el estado. El ejemplo utiliza Button de React Native, pero el comportamiento es idéntico en React DOM:
function App() {
const [count, setCount] = useState(0);
function handlePress() {
const start = Date.now();
while (Date.now() - start < 5000) {
// intentionally blocking JavaScript
}
setCount(c => c + 1);
}
return (
<Button
title={`Count: ${count}`}
onPress={handlePress}
/>
);
}
Durante esos cinco segundos, el hilo de JavaScript:
JavaScript Thread
se ve así:
██████████████████████████████ BLOCKED FOR 5 SEC
Las actualizaciones en cola, una prioridad urgente, un árbol Fiber listo para procesarse: nada de esto importa, porque React en sí mismo es JavaScript y necesita su turno en el hilo. El programador de tareas no puede arrebatar la CPU desde fuera del entorno de ejecución.
¿Quién tiene el control?
Es tentador imaginar que React está arriba, dirigiendo a JavaScript:
React Scheduler
↓
Controls JavaScript
La imagen precisa es la inversa. El entorno de ejecución otorga tiempo de procesamiento, y el programador de tareas de React decide cómo utilizarlo:
JavaScript Runtime
↓
Provides execution opportunity
↓
React Scheduler
↓
Coordinates React work
El programador de tareas opera íntegramente dentro del entorno que proporciona JavaScript. Puede organizar de manera inteligente el trabajo propio de React, pero solo actúa cuando se le asigna un hilo.
Entonces, ¿para qué sirve un programador de tareas?
Si React no puede sobrescribir el bucle de eventos, ¿qué aporta su programador de tareas? Considere una pantalla con miles de elementos:
App
├── Header
├── Search
├── Filters
├── ProductList
│ ├── Product
│ ├── Product
│ ├── Product
│ ├── ...
│ └── Product × 5000
└── Footer
Bajo un modelo puramente síncrono, renderizar este árbol constituye un único bloque continuo e ininterrumpido:
Start Render
↓
████████████████████████████
↓
Finish Entire Render
Mientras tanto, el usuario sigue interactuando:
Type
Tap
Scroll
Si el renderizado es una sola tarea enorme, esas interacciones quedan en la cola hasta que finalice, lo que hace que la interfaz parezca atascada. Lo que necesita React es la capacidad de dividir el renderizado en unidades y cooperar con el sistema anfitrión, devolviendo regularmente el hilo para que se puedan procesar las entradas y realizar los dibujos.
Programación de tareas cooperativa
El programador interno de React es exactamente ese tipo de sistema cooperativo. En lugar de seguir las instrucciones antiguas:
Render everything NOW
pregunta continuamente una serie de cuestiones sobre el trabajo pendiente:
There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?
Desde el punto de vista del flujo, el bucle se ve así:
Pending React Work
↓
Scheduler
↓
Check Priority
↓
Perform Some Work
↓
Should We Yield?
↙ ↘
YES NO
↓ ↓
Give Host Continue
a Chance Rendering
React realiza una parte del trabajo, verifica si debe ceder el control y, de ser así, lo devuelve al entorno anfitrión para que el navegador (o el entorno de ejecución nativo) pueda procesar entradas, ejecutar otras tareas y dibujar la interfaz. Luego continúa desde donde se detuvo o reconsidera qué tarea realizar a continuación.
La palabra clave es cooperativo. React no quita nada a JavaScript; estructura su propio trabajo de modo que nunca retiene el hilo por más tiempo del necesario. Sin embargo, la cooperación solo funciona si todos los participantes colaboran.
Dos capas, dos preguntas
Es útil expresar la diferencia como dos preguntas separadas.
- El bucle de eventos se pregunta: ¿cuándo puede JavaScript ejecutar el trabajo en cola?
- El programador de tareas de React se pregunta: ahora que React tiene el hilo, ¿qué tarea de React merece atención primero?
Juntos, los niveles se ven así:
Browser / Native Runtime
↓
JavaScript Runtime
↓
Event Loop
↓
JavaScript gets to run
↓
React Scheduler
↓
Choose React Work
↓
Fiber
↓
Render / Reconcile
↓
Commit
Cada nivel opera dentro del que está por encima de él.
Cuando la escritura compite con una renderización extensa
Imagínese a React en medio de la renderización de una larga lista de productos:
Rendering Products...
██████████████░░░░░░░░
Luego el usuario comienza a escribir una consulta de búsqueda, una tecla a la vez:
R
Re
Rea
React
Ahora React tiene dos tipos de tareas compitiendo por el mismo hilo:
Large List Rendering
+
User Input Update
Al usuario le importa mucho más la escritura: cada carácter debe aparecer al instante, incluso si la lista se retrasa un poco. Priorizar las teclas y interrumpir la renderización de la lista lo hace posible.
Cómo Fiber proporciona los puntos de interrupción
La programación por lotes solo es útil si el trabajo realmente puede dividirse. Ese es el papel de Fiber: cada fibra representa el trabajo correspondiente a una parte del árbol, de modo que la renderización se convierte en una secuencia de pasos pequeños en lugar de una operación monolítica:
App Fiber
↓
Header Fiber
↓
Search Fiber
↓
List Fiber
↓
Product Fiber
↓
Product Fiber
↓
...
Con la renderización concurrente, React puede procesar algunos de esos pasos, detenerse para atender algo más urgente y luego continuar o empezar de nuevo:
Start Rendering
↓
Perform Some Fiber Work
↓
Pause
↓
Handle More Urgent Work
↓
Continue / Restart
La documentación de React describe la renderización concurrente como interrumpible precisamente en este sentido: una renderización puede iniciarse, pausarse, reanudarse más tarde o abandonarse si se ha vuelto obsoleta, todo antes de que nada sea confirmado. Eso es una afirmación muy diferente a decir que React ejecuta JavaScript en paralelo, lo cual no hace.
Concurrente no es paralelo
La frase:
Concurrent React
A menudo se imagina la existencia de hilos dedicados para cada tarea:
Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering
Ese modelo es incorrecto. La concurrencia en React se basa en la intercalación de operaciones en un único hilo, más bien así:
Work A
██████░░░░░░ ↓ pauseWork B
████ ↓Work A
██████
La tarea A se ejecuta por un tiempo, hace una pausa, luego se ejecuta la tarea B y después vuelve a continuar la tarea A. Las diferentes partes del proceso de renderizado se coordinan con el paso del tiempo, pero el código de tu componente nunca se ejecuta en varios hilos al mismo tiempo.
Tareas urgentes que llegan durante una transición
Supongamos que React está procesando una actualización costosa que ha sido envuelta en una transición:
startTransition(() => {
setResults(expensiveResults);
});
Mientras esa renderización aún está en curso, el usuario escribe otro carácter. La secuencia se vería más o menos así:
Transition Render
██████████░░░░░░░
↓ User Types ↓Urgent Update
████████ ↓React can reconsider
lower-priority work
Dado que la transición no es urgente, React puede dejarla de lado, atender primero la tecla pulsada y luego volver a la tarea de menor prioridad, a menudo reiniciándola con un estado actualizado.
Posponer un valor en lugar de una actualización
useDeferredValue aplica la misma idea del lado de lectura. En lugar de marcar una actualización de estado como no urgente, se obtiene una copia retrasada de un valor:
const deferredQuery = useDeferredValue(query);
La entrada sigue utilizando el estado real y actualizado:
<TextInput
value={query}
onChangeText={setQuery}
/>
mientras que el componente costoso recibe la copia pospuesta:
<ExpensiveList query={deferredQuery} />
Como resultado, el campo ya puede mostrar:
React
mientras que la lista refleja brevemente aún la consulta anterior:
Reac
Mientras tanto, React prepara la nueva lista por separado, y cada nuevo tecleo simplemente hace que descarte ese intento y comience de nuevo con la consulta más reciente. El componente costoso no se vuelve más rápido por esto; el objetivo es únicamente evitar que la renderización menos importante bloquee la interacción en la que está enfocado el usuario. Elegir entre estas dos herramientas suele ser sencillo: utiliza startTransition cuando controlas la actualización del estado, y useDeferredValue cuando solo recibes el valor, por ejemplo como un prop.
La limitación: la programación no puede interrumpir tu código
Todo lo anterior depende de que React recupere regularmente el hilo de ejecución. Ahora considera una función que realiza un cálculo síncrono enorme:
function calculateEverything() {
for (let i = 0; i < 10_000_000_000; i++) {
// huge synchronous calculation
}
}
Mientras se ejecuta, el hilo de JavaScript:
JS THREAD
está completamente ocupado:
████████████████████████████Scheduler waiting...React waiting...Input waiting...
El programador de tareas, React y las entradas pendientes esperan todos juntos. La programación cooperativa solo puede ceder el control entre unidades del propio trabajo de React; no puede interrumpirse dentro de un bucle que hayas escrito tú. Por lo tanto, el renderizado concurrente no es un sustituto del JavaScript eficiente. Los cálculos intensivos siguen necesitando reducirse, dividirse en partes, memorizarse o trasladarse fuera del hilo principal, por ejemplo a un worker.
Por qué el renderizado y la confirmación permanecen separados
Ese mismo modelo explica por qué React insiste en dividir una actualización en dos fases:
Render
y:
Commit
El renderizado es donde React determina cómo debería ser la interfaz de usuario, y en modo concurrente esa preparación puede interrumpirse. Lo que React se niega a hacer es mostrar un resultado incompleto. Así que durante la fase de renderizado:
Render
React puede completar algunas fibras, ser interrumpido y reanudarse o reiniciarse, y solo cuando todo el árbol está listo realiza el commit:
Fiber A ✓
Fiber B ✓
Fiber C ...
↓
Interrupted
↓
Continue / Restart
↓
Entire Tree Ready
↓
COMMIT
↓
Visible UI
Por eso, una renderización interrumpida nunca genera una pantalla que mezcle estados:
Half old UI
+
Half new UI
El commit solo aplica un resultado consistente y completo.
Por qué la renderización debe ser pura
“Mantener los componentes puros durante la renderización” es un consejo conocido, y la renderización interrumpible muestra exactamente por qué es importante. Observe un componente que realiza una llamada de análisis desde su cuerpo:
function Product() {
analytics.track("Product Rendered");
return <View />;
}
Bajo renderización concurrente, React puede seguir una secuencia como esta:
Starts Render
↓
Pauses
↓
Abandons Work
↓
Renders Again
Cada uno de esos intentos ejecuta la función del componente, por lo que esta llamada:
analytics.track(...)
Puede ejecutarse para renders que nunca fueron guardados ni vistos por nadie. Sus métricas contarían vistas fantasma del producto. React necesita la libertad de:
Render
Pause
Retry
Abandon
Restart
sin que ninguno de esos intentos modifique el mundo exterior. Los efectos secundarios deben ir en los effects o en los manejadores de eventos, que solo se ejecutan para el trabajo ya guardado. Para una guía práctica sobre cómo elegir dónde debe residir la lógica, consulte cómo elegir la herramienta adecuada en React: derive, handle, fetch, defer o effect.
El enfoque de React Native
En una aplicación React Native, el camino desde una tecla pulsada hasta la pantalla pasa por las mismas capas, con un renderizador nativo al final:
User Types
↓
JS Event Handler
↓
setState()
↓
React Scheduler
↓
Fiber Work
↓
React Native Renderer
↓
Native UI Update
Si se bloquea el hilo de JavaScript con tareas síncronas costosas, la cadena se detiene en el segundo paso:
User Types
↓
JS Thread Busy
████████████████
↓
React can't process JS work yet
La aplicación parece congelada aunque el hilo de interfaz nativa en sí pueda estar perfectamente funcional. Al diagnosticar lentitud, es útil separar las posibles causas, ya que cada una requiere una solución diferente:
Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?
Demasiadas llamadas de renderizado exigen memorización, virtualización o transiciones. Un hilo de JavaScript bloqueado requiere que el trabajo pesado se realice en otro lugar o se divida. Un trabajo nativo costoso o un hilo de UI sobrecargado indican la necesidad de realizar un perfilamiento nativo.
El proceso completo
Al combinar todas las partes, una sola interacción pasa por estas etapas:
USER INTERACTION
↓
JavaScript Runtime
↓
Event Handler
↓
setState()
↓
Update Created
↓
Lane / Priority
↓
React Scheduler
↓
Choose Work
↓
Fiber Render
↓
Can Yield / Restart
↓
Reconciliation
↓
Completed Work
↓
Commit
↓
UI Updated
Y todo esto tiene lugar dentro de los límites del entorno de ejecución de JavaScript:
┌─────────────────────────────┐
│ JavaScript Runtime │
│ │
│ Event Loop gives JS a chance│
│ ↓ │
│ React coordinates │
│ its own work │
└─────────────────────────────┘
El bucle de eventos asigna turnos a JavaScript; dentro de cada turno, React decide cómo utilizarlo.
Preguntas que vale la pena poder responder
¿Es el programador de React lo mismo que el bucle de eventos?
No. El bucle de eventos pertenece al entorno de ejecución del sistema anfitrión. El programador de React coordina el trabajo de React dentro del tiempo que ese entorno le concede.
¿React ejecuta componentes de forma concurrente en varios hilos?
No. La renderización concurrente permite a React pausar, reanudar, reiniciar o abandonar el trabajo de renderización en un único hilo. El código de los componentes no se vuelve paralelo.
¿Puede React interrumpir la renderización?
Sí, en la renderización concurrente. React puede dar prioridad a las actualizaciones urgentes y descartar el trabajo de renderización obsoleto antes de que se guarde.
¿Puede el programador rescatar un hilo bloqueado?
No. El JavaScript síncrono de larga duración hace que React espere, al igual que todo lo demás, hasta que el hilo quede libre.
¿Por qué deben permanecer los componentes puros?
Dado que un renderizado puede ejecutarse más de una vez y puede ser interrumpido o descartado. Los efectos secundarios durante el renderizado se perderían en tareas que nunca llegan a la pantalla.
Cómo evoluciona el modelo mental
La mayoría de los desarrolladores comienzan con la idea de que modificar el estado conduce directamente a un nuevo renderizado:
setState()
↓
React renders
↓
Screen updates
Aprender sobre los “lanes” y la programación de tareas añade algunas etapas intermedias:
setState()
↓
Update
↓
Lane
↓
Scheduler
↓
Render
↓
Commit
La capa final es el propio tiempo de ejecución, que se encuentra por debajo de todo lo que hace React:
JavaScript Runtime
↓
Event Loop
↓
React Scheduler
↓
Fiber
↓
Render
↓
Commit
React programa sus tareas de manera inteligente, pero siempre desde el interior del entorno JavaScript. Visto de esta forma, las transiciones, los renderizables interrumpibles y un hilo JavaScript bloqueado encajan en una imagen coherente.
Hacia dónde se dirige el pipeline
Este artículo siguió el camino desde una actualización de estado hasta el bucle de eventos:
State Update
↓
Update Queue
↓
Fiber
↓
Lanes
↓
Scheduler
↓
JavaScript Event Loop
En React Native, ese camino continúa aún más allá, desde los commits de React hasta las vistas reales de la plataforma. Un árbol tan simple como este:
<View>
<Text>Hello React Native</Text>
</View>
sigue pasando por el bundler, el motor Hermes, JSI y el renderizador Fabric antes de convertirse en la interfaz de usuario nativa de Android o iOS. Esas capas traen consigo sus propios hilos y problemas de programación en tiempo, y merece la pena entenderlas por separado una vez que se haya comprendido bien el lado de JavaScript.
Puntos clave
- El bucle de eventos decide cuándo se ejecuta JavaScript; el programador de tareas de React decide qué trabajo de React realizar cuando llega ese momento.
- La programación en tiempo de React es cooperativa: cede el control entre unidades de trabajo de Fiber para que la entrada y el dibujo puedan continuar.
- La concurrencia en React implica la intercalación de tareas en un único hilo, no una ejecución paralela.
startTransition marca las actualizaciones como no urgentes; useDeferredValue permite que un componente se renderice con un valor retrasado.Lecturas relacionadas
- Cómo process.nextTick() priva silenciosamente al bucle de eventos de Node.js — Explica por qué las llamadas recursivas a process.nextTick() bloquean completamente la fase de monitoreo de libuv y cómo corregir este problema utilizando setImmediate().
- Dentro de React Fiber: Unidades de Trabajo, Render vs Commit y Carriles de Prioridad — Aprenda qué es realmente React Fiber, por qué el antiguo reconciliador de pila bloqueaba el hilo principal, y cómo las unidades de trabajo, las dos fases y los carriles permiten un renderizado concurrente.
- Cómo JavaScript ejecuta su código: Pila, Heap, Bucle de Eventos y Generadores — Un recorrido guiado por el entorno de ejecución de JavaScript: pila de llamadas y frames, referencias del heap, el bucle de eventos de Node.js, el DOM, callbacks, promesas, async/await y generadores.