En artículos previos se cubrieron los hooks y el proceso de commit; este material profundiza en cómo funciona internamente useEffect.
useEffect gestiona efectos secundarios como peticiones HTTP o manipulaciones del DOM. Internamente es un hook más, con su propio memorizedState, updateQueue y puntero next.
Estructura del objeto Effect
El campo memorizedState de un hook useEffect almacena una instancia de Effect, definida en TypeScript así:
export interface Effect {
tag: HookEffectTag;
deps: HookDeps;
execute: EffectCallback | null;
cleanup: EffectCallback | null;
next: Effect;
}
El campo tag utiliza un tipo numérico definido en hookEffectTag.ts:
export const Passive = 0b0010;
export const ShouldRun = 0b0001;
export type HookEffectTag = number;
Passive indica que el hook es un useEffect. ShouldRun señala que dicho efecto debe ejecutarse en la renderización actual.
El campo deps contiene el arreglo de dependencias, similar a useMemo y useCallback. execute es la función pasada a useEffect, mientras que cleanup es la función de limpieza que esta retorna. El puntero next enlaza con el siguiente objeto Effect en la cadena.
Aunque useEffect es un hook, su updateQueue en el nodo hook no se utiliza. En su lugar, el Affect se guarda en memorizedState y la cadena completa se almacena en la updateQueue del Fiber actual.
Cola de actualización para componentes funcionales
Para los Fiber de tipo función, la updateQueue almacena los objetos Effect. Existe una clase especializada que extiende la cola de actualización genérica:
export class FCUpdateQueue<State> extends UpdateQueue<State> {
public tail: Effect | null = null;
}
El puntero tail apunta al último nodo de una lista circular de efectos.
Flujo general de useEffect
Durante cada actualización, tanto mountEffect como updateEffect determinan si el efecto debe ejecutarse:
- Si debe ejecutarse, se inserta en la
updateQueuedel Fiber con el tagPassive | ShouldRun. - Si no debe ejecutarse, igualmente se inserta, pero solo con el tag
Passive.
Además, se marca el Fiber con la bandera PassiveEffect.
Durante la fase de commitMutation, si un nodo posee la bandera PassiveEffect, su updateQueue se añade al arreglo root.pendingPassiveEffects. Posteriormente, en la fase commitPassive, se procesan todos los efectos acumulados en dicho arreglo.
Fase de montaje
En el montaje, useEffect invoca internamente mountEffect:
- Se obtiene el nodo hook actual.
- Se añade la bandera
PassiveEffectal Fiber en renderizado. - Se crea un objeto Effect con el tag
Passive | ShouldRuny se almacena tanto enmemorizedStatecomo en laupdateQueuedel Fiber.
function mountEffect(
callback: EffectCallback,
deps: HookDeps
): EffectCallback | void {
const hookNode = mountWorkInProgressHook();
(currentRenderingFiber as FiberNode).flags |= PassiveEffect;
hookNode.memorizedState = enqueueEffect(
Passive | ShouldRun,
callback,
null,
deps
);
}
En la fase de montaje todos los efectos se ejecutan al menos una vez, por eso el tag incluye ShouldRun.
Función enqueueEffect
Esta función actúa como constructor del objeto Effect y lo enlaza a la updateQueue del Fiber:
function enqueueEffect(
tag: HookEffectTag,
callback: EffectCallback | null,
cleanup: EffectCallback | null,
deps: HookDeps
) {
const newEffect: Effect = {
tag,
execute: callback,
cleanup,
deps: deps === undefined ? null : deps,
next: null,
};
const queue = currentRenderingFiber.updateQueue;
if (!queue || !(queue instanceof FCUpdateQueue)) {
const freshQueue = new FCUpdateQueue<Effect>();
newEffect.next = newEffect;
freshQueue.tail = newEffect;
currentRenderingFiber.updateQueue = freshQueue;
} else {
const existingQueue = queue as FCUpdateQueue<Effect>;
if (existingQueue.tail) {
newEffect.next = existingQueue.tail.next;
existingQueue.tail.next = newEffect;
existingQueue.tail = newEffect;
}
}
return newEffect;
}
Si la updateQueue del Fiber está vacía (se reinicia durante renderWithHook), se crea una nueva instancia de FCUpdateQueue. De lo contrario, el nuevo efecto se añade al final de la lista circular existente.
Fase de actualización
En actualizaciones subsecuentes, la updateQueue del Fiber se vacía, por lo que updateEffect debe reconstruir la lista de efectos basándose en la información almacenada previamente en hook.memorizedState.
La decisión de reejecutar un efecto depende de la comparación entre las dependencias actuales y las anteriores mediante areHookInputsEqual:
- Si coinciden, el tag se establece solo como
Passive(no se ejecuta). - Si difieren, el tag incluye
ShouldRunademás dePassive.
function updateEffect(
callback: EffectCallback,
deps: HookDeps
): EffectCallback | void {
const hookNode = updateWorkInProgressHook();
const previousDeps = hookNode.memorizedState.deps;
const previousCleanup = hookNode.memorizedState.cleanup;
if (areHookInputsEqual(previousDeps, deps)) {
hookNode.memorizedState = enqueueEffect(
Passive,
callback,
previousCleanup,
deps
);
} else {
hookNode.memorizedState = enqueueEffect(
Passive | ShouldRun,
callback,
previousCleanup,
deps
);
}
(currentRenderingFiber as FiberNode).flags |= PassiveEffect;
}
Todos los efectos, tanto en montaje como en actualización, terminan en la updateQueue del Fiber. El tag ShouldRun determina si se ejecutan o no.
Recolección de efectos durante el commit
La bandera PassiveEffect se propaga hacia la raíz durante completeWork. En commitRoot, si la raíz o su subárbol contienen esta bandera, se programa la ejecución asíncrona de los efectos pasivos:
if (
(finishedWork.flags & PassiveMask) !== NoFlags ||
(finishedWork.subTreeFlags & PassiveMask) !== NoFlags
) {
scheduler.scheduleCallback(
PriorityLevel.NORMAL_PRIORITY,
flushPendingEffects.bind(null, root.pendingPassiveEffects)
);
}
La estructura de pendingPassiveEffects es la siguiente:
export interface PendingPassiveEffects {
updatedEffects: Effect[];
unmountedEffects: Effect[];
}
Contiene dos arreglos: uno para efectos que deben actualizarse y otro para efectos de componentes desmontados.
Puntos de recolección
La función capturePassiveEffect se encarga de recolectar los efectos:
function capturePassiveEffect(
fiber: FiberNode,
root: FiberRootNode,
action: "update" | "unmount"
) {
if (fiber.tag !== FunctionComponent) return;
if (action === "update" && (fiber.flags & PassiveEffect) === NoFlags) return;
const effectQueue = fiber.updateQueue as FCUpdateQueue<Effect>;
if (effectQueue && effectQueue.tail) {
root.pendingPassiveEffects[action].push(effectQueue.tail);
}
}
Esta función se invoca en dos ocasiones:
- Durante
commitMutationEffectsOnFiber, para recolectar efectos de componentes actualizados. - Durente
commitDeletion, para recolectar efectos de componentes desmontados (sin requerir la banderaPassiveEffect).
Ejecución de efectos pasivos
La función flushPendingEffects procesa todos los efectos recolectados tras la fase de mutación:
function flushPendingEffects(pending: PendingPassiveEffects) {
pending.unmountedEffects.forEach((effectChain) => {
runCleanupList(Passive, effectChain);
});
pending.unmountedEffects = [];
pending.updatedEffects.forEach((effectChain) => {
runCleanupList(Passive | ShouldRun, effectChain);
});
pending.updatedEffects.forEach((effectChain) => {
runCreateList(Passive | ShouldRun, effectChain);
});
pending.updatedEffects = [];
}
El orden es: primero limpiar efectos desmontados, luego ejecutar las limpiezas de efectos actualizados, y finalmente ejecutar las funciones de creación de efectos actualizados.
La función runCreateList recorre la cadena circular de efectos, ejecuta aquellos cuyo tag coincida con el filtro y almacena la función de limpieza retornada:
function runCreateList(
filter: HookEffectTag,
lastEffect: Effect | null
) {
traverseEffectChain(filter, lastEffect, (effect) => {
if (typeof effect.execute === "function") {
effect.cleanup = effect.execute() as EffectCallback;
}
});
}
De forma similar, runCleanupList ejecuta las funciones de limpieza de los efectos que coincidan con el filtro:
function runCleanupList(
filter: HookEffectTag,
lastEffect: Effect | null
) {
traverseEffectChain(filter, lastEffect, (effect) => {
if (typeof effect.cleanup === "function") {
effect.cleanup();
}
});
}
Para el caso de desmontaje, se ejecuta la limpieza de todos los efectos y se elimina el flag ShouldRun:
function runCleanupUnmount(
filter: HookEffectTag,
lastEffect: Effect | null
) {
traverseEffectChain(filter, lastEffect, (effect) => {
if (typeof effect.cleanup === "function") {
effect.cleanup();
}
effect.tag &= ~ShouldRun;
});
}
La función auxiliar traverseEffectChain itera sobre la lista circular de efectos y aplica un callback a aquellos cuyo tag contenga los bits del filtro:
function traverseEffectChain(
filter: HookEffectTag,
lastEffect: Effect | null,
handler: (effect: Effect) => void
) {
let current = lastEffect!.next;
do {
if ((filter & current.tag) === filter) {
handler(current);
}
current = current.next;
} while (current !== lastEffect!.next);
}
Dado que la fase de commitMutation ocurre durante la etapa de "retorno" del árbol (fase de reconciliación descendente y retorno ascendente), los efectos se recolectan de abajo hacia arriba, lo que garantiza que los componentes hijos se limpien antes que los padres.