Cómo funcionan los hooks de React por dentro: Fiber y orden
El PR venía con una nota: "optimización menor". Dentro, un useState movido dentro de un if, porque ese estado solo hacía falta si el usuario era admin. Tenía su lógica: para qué reservar memoria de algo que el 95% de la gente no usa.
El lint saltó. El autor lo silenció con un // eslint-disable-next-line. Dos días después la app reventó: Rendered fewer hooks than expected.
Entender cómo funcionan los hooks de React por dentro no es curiosidad académica: es lo que vuelve evidente esa regla. Porque "no llames hooks dentro de condicionales" la obedece todo el mundo sin saber por qué existe, y una regla que obedeces por fe acabas rompiéndola.
No es un capricho del equipo de React. Es la consecuencia directa de dónde está guardado tu estado.
En corto: tu estado no vive en la función del componente, vive en el nodo Fiber que React mantiene por cada instancia montada, dentro de una lista enlazada que cuelga del campo memoizedState. React no guarda el nombre de cada hook: recorre esa lista en orden, un nodo por llamada. Si el orden cambia entre renders, React entrega el estado equivocado a la llamada equivocada.
¿Qué es un hook de React por dentro?
Un hook es una entrada de una lista enlazada asociada a un nodo Fiber: un objeto de cinco campos —memoizedState, baseState, baseQueue, queue y next— que React crea en el primer render y recupera por posición en todos los siguientes.
No es una metáfora. Es el tipo literal, tal cual está en packages/react-reconciler/src/ReactFiberHooks.js:
export type Hook = {
memoizedState: any,
baseState: any,
baseQueue: Update<any, any> | null,
queue: any,
next: Hook | null,
};
Y en el mismo fichero, un comentario que ahorra media hora de lectura: "Hooks are stored as a linked list on the fiber's memoizedState field."
Ojo con la trampa de nombres. En el Fiber, memoizedState apunta al primer hook de la lista. En cada Hook, memoizedState guarda el valor de ese hook concreto. Mismo nombre, dos niveles distintos.
Un Fiber es el objeto que React mantiene por cada elemento del árbol —componente, nodo del DOM o fragmento— y que guarda su estado, el trabajo pendiente y su posición en el árbol; React 16 lo introdujo en 2017 para poder pausar, abandonar y reanudar el renderizado. Con su puntero alternate al Fiber del render anterior, es lo que sobrevive entre renders. Tu componente solo se ejecuta y muere.
Por qué el orden de llamada de los hooks de React lo es todo
Si React guarda los hooks en una lista y no guarda nombres, solo le queda una manera de saber cuál te toca: contar.
La forma más rápida de que haga clic es escribir la versión de juguete. Ojo — es una simplificación pedagógica: React usa una lista enlazada por Fiber, no un array global. La identidad por posición, en cambio, funciona igual.
// useState casero. React usa una lista enlazada por Fiber, no un array global:
// esto es una simplificación para ver el mecanismo del orden.
let hooks = [];
let cursor = 0;
function useState(initialValue) {
const index = cursor; // esta llamada se queda con esta posición
cursor++; // la siguiente cogerá la siguiente
if (!(index in hooks)) {
hooks[index] = initialValue; // primer render: monta
}
const setState = (next) => {
// igual que basicStateReducer en React: si es función, la aplica
hooks[index] = typeof next === 39;function39; ? next(hooks[index]) : next;
render();
};
return [hooks[index], setState];
}
function render() {
cursor = 0; // el cursor vuelve a cero al empezar cada render
Component(); // tu componente: React lo vuelve a ejecutar
}
La línea que importa es cursor = 0: el array persiste, el cursor se reinicia. La identidad de cada hook es su posición en la secuencia de llamadas.
Ahora mete un condicional en medio:
function Perfil({ esAdmin }) {
const [nombre, setNombre] = useState(39;Bezael39;); // índice 0
if (esAdmin) {
const [permisos] = useState([]); // índice 1 ... a veces
}
const [tema, setTema] = useState(39;oscuro39;); // índice 1 o 2, según el día
}
Primer render con esAdmin: true: se montan tres hooks. nombre en 0, permisos en 1, tema en 2.
El usuario pierde el rol. Segundo render con esAdmin: false: solo hay dos llamadas, así que tema lee el índice 1 — donde estaba permisos. Durante ese render tu string 'oscuro' es un array vacío, y al terminar el componente React ve que sobraba un hook en la lista y revienta con Rendered fewer hooks than expected: el error del PR de arriba.
El caso caro es el que no altera el conteo — un if/else con un useState en cada rama. Mismo número de llamadas, ningún error, valor equivocado. React no puede echar en falta lo que no falta.
Las comprobaciones que sí tiene viven en la propia lista. En updateWorkInProgressHook, si pides un hook que no existía antes:
throw new Error(39;Rendered more hooks than during the previous render.39;);
Y al terminar el render, si faltan hooks respecto a la lista previa, salta el otro: "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." En desarrollo hay además un aviso que compara la secuencia hook a hook: "React has detected a change in the order of Hooks called by…".
Los tres mensajes que vas a ver, y qué significa cada uno:
| Mensaje | Cuándo salta | Qué lo causó | Dónde vive |
|---|---|---|---|
Rendered more hooks than during the previous render. |
Durante el render | Este render pide un hook que el anterior no tenía: el if se abrió |
updateWorkInProgressHook |
Rendered fewer hooks than expected. This may be caused by an accidental early return statement. |
Al terminar el render | Faltan llamadas respecto a la lista previa: un return temprano o un if que se cerró |
finishRenderingHooks |
React has detected a change in the order of Hooks called by... |
Solo en __DEV__ |
Mismo número de hooks, distinto orden: compara la secuencia hook a hook | aviso de desarrollo |
| (ninguno) | Nunca | El caso peligroso: el tipo de hook coincide y el valor se corrompe en silencio | — |
Esa última fila es la que importa. Los tres errores son el caso amable.
No fue un accidente que luego hubo que justificar: fue una decisión discutida en público. El RFC de Hooks lo abrió Sebastian Markbåge en octubre de 2018, y Dan Abramov dedicó un artículo entero a las alternativas descartadas, Why Do React Hooks Rely on Call Order? (13 de diciembre de 2018). Sobre identificar hooks por nombre en vez de por posición, escribió:
"With this proposal, any time you add a new state variable inside a custom Hook, you risk breaking any components that use it (directly or transitively) because they might already use the same name for their own state variables."
Y sobre por qué tampoco compensaba arreglarlo con más lint: "But if we have to lint anyway, what problem did we solve?".
Ese es el trueque: React se traga una regla incómoda a cambio de que los custom hooks compongan sin colisiones de nombres.
Mount y update son dos funciones distintas
Segunda pieza: useState no es una función. Son dos.
React no importa useState desde el reconciler. Lo lee de un dispatcher, un objeto que apunta a una implementación u otra según el momento. La línea vive en renderWithHooks, la función que envuelve la ejecución de tu componente:
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
mountState crea el nodo: reserva el hook, guarda el valor inicial en memoizedState y baseState, monta la cola y ata el dispatch. Ahí está, de paso, por qué el lazy initializer se ejecuta una sola vez: el typeof initialState === 'function' vive dentro de mountStateImpl, y a esa rama no vuelves nunca. updateState no crea nada: recupera el hook por posición y calcula el valor procesando su cola con basicStateReducer.
El montaje son estas líneas:
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null, baseState: null,
baseQueue: null, queue: null, next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
Hay más dispatchers. Uno es ContextOnlyDispatcher: el famoso "Invalid hook call" no es una comprobación mágica, es que el puntero apunta a un objeto cuyos métodos, salvo use y readContext, solo saben lanzar errores.
Colas de actualización y comparación de dependencias
Cuando llamas a setState no pasa casi nada: React crea un objeto Update, lo mete en una cola circular colgada de hook.queue.pending y programa trabajo. El valor nuevo se calcula durante el render.
Pero hay un atajo que explica un comportamiento confuso. En dispatchSetStateInternal, si la cola está vacía React calcula el estado siguiente antes de renderizar y compara:
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
Por eso setCount(count) con el mismo valor no siempre provoca un render: el atajo solo aplica si la cola estaba vacía. Si ya había algo pendiente, React renderiza igual. Y aun cuando el atajo entra, la documentación avisa de que React puede necesitar ejecutar tu componente una vez antes de saltarse a los hijos.
La comparación de dependencias de useEffect, useMemo y useCallback es aún más simple. areHookInputsEqual recorre el array posición a posición:
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
Ese is viene de shared/objectIs, que es Object.is. Superficial, elemento a elemento, sin recursión. Un objeto literal nuevo en cada render nunca pasa el test, porque Object.is({}, {}) es false. Ahí está la causa de casi todos los "mi efecto se dispara en bucle": un objeto o un array creado en el cuerpo del componente y metido tal cual en el array de dependencias.
Y ojo con la salida fácil: validar ese objeto tampoco lo estabiliza — cada parse devuelve una referencia nueva. Lo que estabiliza es depender de primitivas (user.id, no user), y para eso necesitas tener escrito el contrato de esos datos: es lo que trabajo en el curso de Zod.
El stale closure: el bug que no es un bug
Un stale closure es una función que sobrevive al render en el que nació y sigue leyendo las props y el estado de aquella ejecución concreta, no los actuales. Con el modelo mental montado, el clásico se explica solo:
function Contador() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // siempre 0
setCount(count + 1); // siempre 0 + 1
}, 1000);
return () => clearInterval(id);
}, []); // el array vacío congela el render nº 1
}
La callback capturó el count del primer render. No es una referencia a "el estado": es una constante de aquella ejecución de la función. El Fiber avanza; esa closure no.
No es una rareza, es un roce de diseño que la comunidad llevó al repositorio. En el issue "Design decision: why do we need the stale closure problem in the first place?", abierto por Sébastien Lorber en septiembre de 2019, el argumento era este:
"Coupling the dependencies of the closure and the conditions to trigger effect re-execution does not make much sense to me."
Tardó años, pero React acabó dándole parte de razón. Las tres salidas, de más vieja a más nueva:
- Updater funcional:
setCount(c => c + 1). React aplica tu función sobre el estado real durante el render, no sobre la copia congelada. - Ref: guardas el valor en un
useRefy leesref.currentdentro del intervalo. useEffectEvent, estable desde React 19.2: saca del efecto la parte que lee lo último, sin que ese valor entre en las dependencias.
useState, useRef y useMemo: la misma caja, distinto contrato
Los tres guardan cosas en hook.memoizedState. Lo que cambia es qué guardan y quién avisa a React.
Qué hay en hook.memoizedState |
Cuándo cambia | ¿Provoca render? | Límite / riesgo real | |
|---|---|---|---|---|
useState |
El valor y una queue con pending, lastRenderedReducer y lastRenderedState |
Al procesar la cola durante el render | Sí — dispatchSetState programa trabajo |
Lees un snapshot del render actual, no "el estado ahora". Origen de casi todos los stale closures |
useRef |
El objeto {current: initialValue}, creado una vez y devuelto siempre |
Cuando mutas .current, al instante |
No — React ni se entera | Si la UI depende de ese valor, no se repinta. Leerlo en el render rompe la pureza |
useMemo |
La tupla [valor, deps] |
Si areHookInputsEqual da false |
No | Es una pista, no una garantía: React puede descartar el cache |
useCallback |
La tupla [callback, deps] |
Igual que useMemo |
No | Estabiliza la referencia, no el contenido: sigue capturando los valores de su render |
useRef y useState son la misma caja con distinto contrato frente al render. Y la última columna es la que más cuesta: memorizar no es gratis. mountMemo guarda un array por hook y updateMemo recorre las dependencias en cada render. Si el cálculo cuesta menos que comparar sus dependencias, useMemo te hace la app más lenta y más difícil de leer.
Los custom hooks de React no tienen magia
Un custom hook es una función que llama a hooks. Punto. No hay registro, no hay instancia, no hay contexto propio.
function useUsuario(id) {
const [datos, setDatos] = useState(null); // ocupa la siguiente posición libre
const [cargando, setCargando] = useState(true);
useEffect(() => { /* ... */ }, [id]);
return { datos, cargando };
}
Esos tres hooks se insertan en la lista del componente que llama, en el punto exacto de la secuencia donde estaba la llamada. El cursor no distingue entre "hooks míos" y "hooks del custom hook". Por eso componen sin colisiones: las llamadas a función forman un árbol, y un árbol se recorre en orden.
Y por eso la regla sube hacia arriba: si metes un if dentro de un custom hook, rompes el orden de todos los componentes que lo usan aunque su código se vea impecable. Para la parte de tipos, lo desmonté aparte en cómo tipar props, hooks y contextos en TypeScript.
Render y commit: dos fases, y solo una toca el DOM
La fase de render ejecuta tu función, recorre la lista de hooks y calcula el árbol nuevo. Es interrumpible: React puede empezarla, abandonarla y rehacerla con otra prioridad. Por eso tu componente tiene que ser puro — si escribes en una variable externa durante el render, esa escritura puede ocurrir dos veces o ninguna. Strict Mode ejecuta tu componente dos veces en desarrollo —y monta, desmonta y vuelve a montar los efectos— a propósito para sacar a la luz ese tipo de bug. La fase de commit aplica los cambios al DOM y ejecuta los efectos, y esa no se interrumpe.
El batching vive entre las dos. Desde React 18, con createRoot, React agrupa todas las actualizaciones antes de renderizar vengan de donde vengan. Dan Abramov lo dejó escrito en la discusión del Working Group de React 18:
"Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default."
Tres setState dentro de un fetch().then() daban tres renders en React 17. Hoy dan uno. Cómo se coloca ese trabajo en la cola del navegador está en cómo los microtasks afectan a la renderización.
Contrástalo con el otro modelo mental del frontend actual: un signal guarda el valor en el propio objeto y notifica a quien lo lee; un hook lo guarda en el Fiber y obliga a reejecutar el componente entero. Comparé ambos en cómo funcionan los Signals en Angular 22 y React 19, y esa reactividad granular llevada a producción es la base del curso de Angular Moderno.
Lo que este modelo mental NO te resuelve
Es un detalle de implementación, y cambia. Nada de lo que has leído es API pública. Campos como baseQueue, lanes o revertLane llegaron con el modo concurrente y se han movido de sitio más de una vez. Sirve para razonar y depurar; no escribas código que dependa de ello.
Saber los internals no arregla un useEffect mal planteado. Entender areHookInputsEqual te dice por qué tu efecto se dispara en bucle. No te dice que ese efecto no debería existir. La mayoría de los que reviso son estado derivado que debería calcularse en el render. El array de dependencias es el síntoma, no la enfermedad.
El React Compiler cambia parte del cálculo. Desde la versión 1.0 estable, del 7 de octubre de 2025, el compilador inserta la memoización en tiempo de build y tu intuición sobre "cuándo compensa un useMemo" deja de aplicarse igual. Ojo con el entusiasmo: la guía oficial recomienda "leaving existing memoization in place (removing it can change compilation output)". El orden de llamada, en cambio, no lo toca — se apoya en él, porque necesita que cumplas las reglas para poder optimizar.
Qué hacer con esto hoy
Abre el componente más grande que tengas y cuenta sus hooks. Luego, hook a hook: ¿este valor necesita provocar un render, o me vale un useRef? ¿Este useMemo cuesta más que comparar sus dependencias? ¿Este efecto lee algo del pasado sin darse cuenta? Veinte minutos, y te va a quitar código.
La próxima vez que alguien proponga meter un hook dentro de un if "porque tiene sentido", ya no tienes que apelar a la autoridad del lint. Le enseñas el cursor y se acabó la discusión.
Publico desmontajes así cada semana en el canal de YouTube, y el material largo con proyectos completos vive en Dominicode Labs.
Preguntas frecuentes
¿Por qué no puedo llamar a un hook dentro de un if?
Porque React no identifica los hooks por su nombre, sino por su posición en la secuencia de llamadas. Los guarda en una lista enlazada colgada del campo memoizedState del Fiber y la recorre en orden en cada render.
Si un condicional hace que una llamada aparezca en un render y no en el siguiente, las posiciones posteriores se desplazan y cada hook recibe el estado del de al lado. React lanza "Rendered more hooks than during the previous render" o "Rendered fewer hooks than expected" en parte de estos casos, pero no en todos: algunos te corrompen el valor en silencio.
¿Dónde guarda React el estado de useState exactamente?
En el nodo Fiber del componente, no en la función. Cada Fiber tiene un campo memoizedState que apunta al primer hook de una lista enlazada, y cada hook es un objeto con los campos memoizedState, baseState, baseQueue, queue y next.
El valor actual de tu useState vive en el memoizedState de su hook; las actualizaciones pendientes, en una cola circular dentro de queue.pending.
¿Qué es un stale closure en React y cómo lo evito?
Es una función que sobrevive a su render y sigue viendo los valores de props y estado del momento en que se creó. El caso típico es un setInterval dentro de un useEffect con dependencias vacías: la callback capturó el estado del primer render y no lo ve cambiar nunca.
Tienes tres salidas: la forma funcional de actualizar (setCount(c => c + 1)), un useRef cuyo .current mantienes al día, o useEffectEvent, estable desde React 19.2.
¿Cuál es la diferencia real entre useRef y useState?
La misma caja con distinto contrato. useState guarda el valor y una cola de actualizaciones, y llamar a su setter programa un render. useRef guarda un objeto { current: valor } que React crea una sola vez y devuelve idéntico en todos los renders: mutar .current es instantáneo y React ni se entera.
Usa useRef para lo que no debe repintar la pantalla y useState para lo que la UI tiene que reflejar.
¿Qué es React Fiber?
Es la arquitectura interna del reconciliador de React desde la versión 16 (2017). Cada elemento del árbol —componente, nodo del DOM, fragmento— tiene su propio objeto Fiber que guarda su estado, el trabajo pendiente y sus punteros al resto del árbol.
Su razón de ser es que el renderizado se pueda interrumpir: React puede empezar a construir un árbol, abandonarlo a medias y rehacerlo con otra prioridad. El campo memoizedState de cada Fiber es donde cuelga la lista enlazada de hooks de ese componente.
¿Por qué mi useEffect se ejecuta en bucle infinito?
Casi siempre porque una de sus dependencias es un objeto, un array o una función que creas en el cuerpo del componente. React compara las dependencias con areHookInputsEqual, que recorre el array posición a posición usando Object.is: superficial, sin recursión.
Object.is({}, {}) es false, así que un literal nuevo en cada render nunca pasa el test, el efecto vuelve a ejecutarse, cambia el estado, provoca otro render y vuelta a empezar. Saca el valor fuera del componente, memorízalo, o pregúntate si ese efecto debería existir.
¿Qué significa el error "Invalid hook call"?
Que llamaste a un hook cuando el dispatcher activo era ContextOnlyDispatcher: un objeto cuyos métodos, salvo use y readContext, solo saben lanzar ese error. No hay detección mágica, hay un puntero apuntando al sitio equivocado.
Pasa en tres situaciones: llamar al hook fuera de un componente o custom hook, tener dos copias de React en el árbol de dependencias, o una desincronización entre react y react-dom.
¿Puedo confiar en estos internals para escribir código?
No. Nada de esto es API pública y el reconciler se reescribe cada pocas versiones: para escribir código, la fuente sigue siendo la documentación oficial y el plugin de ESLint.
El valor de conocer los internals es otro: depurar más rápido, entender los mensajes de error y dejar de obedecer las reglas por fe.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
