Por qué setState nunca vuelve a renderizar al instante: la programación de actualizaciones en React
Sigue una actualización de estado en React desde el objeto de actualizaciones en cola, pasando por la marcación de rutas y la programación, hasta el bucle de trabajo que procesa primero la raíz y omite los subárboles no afectados.
Fuentes: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState
Casi todo desarrollador de React tiene una suposición tácita: pensar que llamar a un setter de estado como setCount(count + 1) vuelve a ejecutar el componente justo en ese momento, antes de que se ejecute la línea siguiente. En realidad no es así, y la diferencia entre la llamada y el nuevo renderizado explica el concepto de agrupamiento de actualizaciones, por qué los componentes no relacionados permanecen sin cambios y por qué leer el estado inmediatamente después de modificarlo devuelve el valor anterior. Este artículo sigue una sola actualización a través de la arquitectura de fibra de React, desde el momento en que se registra hasta que el nuevo valor llega al estado del componente, para que pueda comprender qué hacen realmente sus setters.
El componente bajo el microscopio
Un contador mínimo es suficiente para mostrar todo el mecanismo. Almacena un valor de estado y lo incrementa al hacer clic.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
La llamada setCount(count + 1) parece una función síncrona normal, por lo que es lógico pensar que React invocará nuevamente a Counter() como consecuencia directa de esa línea. Pero no ocurre nada de eso. El setter devuelve sin renderizar nada. En su lugar, registra una solicitud y pide que el trabajo se realice más tarde.
Paso uno: el setter registra una actualización
Cuando se ejecuta setCount(1), React asigna en memoria un pequeño objeto de actualización que describe dicha solicitud. Conceptualmente se ve así:
{
action: 1,
next: null,
}
El campo action almacena lo que se pasó, en este caso el valor 1. El campo next existe porque una sola actualización rara vez es suficiente. Llame al setter tres veces dentro de un mismo manejador y React creará tres objetos de actualización vinculados entre sí mediante next, formando una pequeña lista adjunta al componente. En el código fuente real de React, la actualización también lleva consigo un nivel de prioridad y la lista pendiente se almacena como una estructura circular, pero la idea esencial es una cola de intenciones, no una vuelta a renderizar.
En un modelo simplificado de la fibra, la actualización se adjunta de esta manera:
{
type: Counter,
memoizedState: { count: 0 }, // still 0, untouched
updateQueue: { action: 1, next: null }, // the note, just added
}
Fíjese en lo que no ha cambiado. El estado almacenado sigue mostrando 0, la pantalla sigue exhibiendo 0, y Counter() aún no ha sido llamado. La única diferencia es que ahora un objeto espera en una cola. (Para mayor precisión: con los ganchos, React mantiene la cola de cada gancho en la entrada correspondiente de esa lista de ganchos dentro de la fibra, en lugar de directamente en un campo llamado updateQueue; memoizedState en una fibra de componente funcional es esa lista de ganchos. El modelo simplificado mostrado anteriormente sirve únicamente para fines didácticos, pero conserva el comportamiento relevante en este caso.)
Esto también explica una sorpresa clásica: si registra el valor de count inmediatamente después de llamar a setCount, verá el valor antiguo, ya que la función del componente que utiliza count aún no se ha ejecutado nuevamente.
Paso dos: scheduleUpdateOnFiber marca un camino y solicita tareas
Inmediatamente después de que la actualización se coloca en cola, React llama a una función interna llamada scheduleUpdateOnFiber. Esta tiene dos responsabilidades separadas, y mantenerlas distintas facilita seguir el resto del proceso.
Marcar el camino hacia la raíz
Primero, React recorre desde la fibra Counter hacia arriba a través de cada puntero padre hasta llegar a la raíz del árbol. En cada ancestro del camino establece un campo llamado childLanes, que en esencia indica “hay tareas pendientes debajo de mí”. La fibra Counter misma cuenta con un campo relacionado, lanes, que significa “yo también tengo tareas pendientes propias”.
Estos campos no son simples booleanos. Son máscaras de bits que codifican la prioridad, un tema por sí mismo; consulte cómo los lanes de React utilizan máscaras de bits para codificar la prioridad de las actualizaciones. Para esta explicación, basta saber que pasan de estar vacíos a no estarlo, dejando un rastro desde el componente actualizado hasta la parte superior.
Posponer la renderización real
En segundo lugar, React solicita que el trabajo de renderizado se ejecute pronto, no dentro del manejador de eventos. El mecanismo exacto depende de la prioridad. El trabajo gestionado por el programador de React generalmente se envía mediante MessageChannel (con setTimeout como solución alternativa en entornos que no lo cuentan), por lo que se ejecuta en una etapa posterior del bucle de eventos. Para eventos discretos y de alta prioridad como los clics, las versiones modernas de React suelen procesar el trabajo como una microtarea inmediatamente después de que finalice el manejador, lo cual sigue siendo después de tu código, pero más pronto. La estrategia exacta ha cambiado entre versiones, así que considere estos detalles como específicos de cada versión.
Sea cual sea el canal, la consecuencia es la misma: la renderización se pospone intencionadamente hasta que su manejador haya devuelto. Eso es lo que hace posible el agrupamiento de operaciones. Tres llamadas a setCount en un mismo manejador generan tres actualizaciones en cola, y una sola pasada de renderización posterior procesa todas ellas juntas en lugar de realizar tres renderizaciones separadas.
Paso tres: la renderización siempre comienza en la raíz
Cuando finalmente se ejecuta el trabajo programado, no comienza en Counter. Comienza en la fibra raíz, el nodo especial que se encuentra por encima de toda su aplicación y que se creó al llamar a ReactDOM.createRoot. Esto ocurre independientemente de cuán profundo esté en el árbol el origen de la actualización.
Empezar desde la raíz parece costoso, pero eso no significa que se tenga que visitar cada nodo. El camino de childLanes definido en el paso dos es exactamente lo que se sigue durante la recorrida. En cada nodo, React verifica si el subárbol inferior tiene tareas pendientes. Si no las tiene, React abandona el proceso y salta todo el subárbol sin adentrarse en él. Los ancestros del camino marcado se procesan de forma rápida, y solo se exploran las ramas que conducen a tareas pendientes.
Un bucle, no recursión
El recorrido se implementa como un bucle en lugar de mediante recursión profunda:
let node = rootFiber;
while (node !== null) {
node = processNode(node); // moves to node.child, node.sibling, or up via node.return
}
Cada fibra contiene tres punteros: child para su primer hijo, sibling para la siguiente fibra en el mismo nivel, y return para su padre. Esos tres enlaces son suficientes para desplazarse hacia los hijos, pasar a los hermanos y volver hacia arriba cuando se termina un ramo, paso a paso. No existe un array de hijos por el que iterar. Dado que la posición en el árbol se almacena en una variable y no en la pila de llamadas, React puede pausarse entre unidades de trabajo y reanudarse más tarde, lo cual constituye la base del renderizado concurrente.
Por qué los componentes no relacionados permanecen intactos
Este es el mecanismo detrás de un efecto que probablemente hayas observado. Escribir en un campo de entrada actualiza únicamente la parte del árbol que posee ese estado. Un componente distante e independiente no se vuelve a renderizar ni siquiera se visita, porque ninguna actualización marcó su ramificación. Este omite no es una optimización especial añadida posteriormente; surge directamente del seguimiento de la ruta childLanes.
Tenga en cuenta los límites de esto. Un componente en la ruta marcada que a su vez tenga tareas pendientes se volverá a renderizar, y por defecto lo harán también todos sus descendientes, estén marcados o no, a menos que estén memorizados. La ruta indica a React hacia dónde dirigirse; no impide que un padre que se vuelve a renderizar lo haga también con sus hijos.
Paso cuatro: llegar a Counter y consumir la cola
Cuando la ejecución llega a Counter, entra en acción el mecanismo de doble búfer. React verifica el puntero alternate de la fibra actual y ya sea reutiliza la fibra en proceso existente o crea una nueva. Es en esa copia en proceso donde se calcula el nuevo estado.
Ahora la cola finalmente se lee. React procesa la actualización pendiente, aplica la acción al estado anterior y escribe el resultado en memoizedState de la fibra en proceso. Durante este paso se llama realmente a Counter(), y useState devuelve el nuevo valor. Si hay varias actualizaciones en cola, se aplican en orden; las actualizaciones funcionales como setCount(c => c + 1) reciben cada una el resultado de la anterior, por lo que se comportan correctamente cuando se agrupan, mientras que setCount(count + 1) tres veces solo produce un incremento.
// work-in-progress fiber, right after processing the queue
{
type: Counter,
memoizedState: { count: 1 }, // finally updated, using the queued action
updateQueue: null, // consumed, nothing left pending
alternate: Fiber A,
}
La cola está ahora vacía y el nuevo estado se encuentra en la fibra de trabajo en progreso. Se han combinado tres mecanismos distintos: la actualización en cola del paso uno, el seguimiento marcado y el recorrido desde la raíz de los pasos dos y tres, así como la pareja de fibra actual/trabajo en progreso que almacena las versiones anterior y posterior. Para un seguimiento más amplio que continúa hasta la reconciliación y el commit del DOM, consulte rastreando una llamada a setState en React desde la cola de actualizaciones hasta el commit del DOM.
La secuencia completa
Si se observa de principio a fin, un único clic pasa por estas etapas:
- El manejador de clic llama a
setCount, y React crea un objeto de actualización que se agrega a la cola del componente.
scheduleUpdateOnFiber establece lanes en Counter y childLanes en cada ancestro hasta la raíz.child, sibling y return, omitiendo los subárboles sin lanes pendientes.Counter, React prepara la fibra en proceso, procesa las actualizaciones en cola y calcula el nuevo estado.Nada de esto ocurre en la línea donde llamaste al setter. Todo se ejecuta posteriormente, en una estructura cuyos campos existen específicamente para soportar este proceso diferido y reanudable.
Puntos clave
- Un state setter pone una actualización en cola y programa su ejecución; nunca la procesa de forma síncrona dentro de su manejador.
- Leer el estado justo después de modificarlo devuelve el valor anterior porque el componente aún no se ha vuelto a ejecutar.
- El agrupamiento de actualizaciones es un resultado natural del diferimiento: todas las actualizaciones puestas en cola antes de la fase de renderizado se procesan de una sola vez.
lanesychildLanesmarcan el camino desde la fibra actualizada hasta la raíz, lo que permite a React omitir los subárboles que no han sido modificados.- El bucle de renderizado utiliza punteros
child,siblingyreturnen lugar de recursión, lo que permite a React pausar y reanudar su trabajo. - Un componente que se vuelve a renderizar sigue procesando sus hijos por defecto; la secuencia de lanes limita dónde busca React, no qué renderiza el padre.
Lecturas relacionadas
- Rastreando una llamada a setState de React desde la cola de actualizaciones hasta el compromiso del DOM — Sigue paso a paso una actualización de estado en React a través de la cola de actualizaciones de los hooks, el programador, la fase de renderizado, la reconciliación y el compromiso, y entiende por qué el estado nunca cambia de inmediato.
- Comprendiendo los canales de React: cómo las máscaras binarias codifican la prioridad de actualizaciones — Aprende cómo React codifica múltiples prioridades de actualización en un único número entero utilizando máscaras binarias, y por qué las operaciones bit a bit reemplazan a las simples banderas booleanas para la programación.