Crea un useState de juguete para comprender snapshots, colas y efectos
Aprende cómo se comportan los hooks de React creando un pequeño useState, y luego utiliza ese modelo para explicar las colas de actualización, los snapshots obsoletos, las cierres, la limpieza de efectos, los refs y el agrupamiento.
Fuentes: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
Una sola línea de código hook oculta una cantidad sorprendente de mecanismos:
const [count, setCount] = useState(0);
React debe decidir dónde se almacena count, cómo sobrevive a cada llamada, qué estado afecta un determinado setter, por qué las llamadas a los hooks no pueden ser condicionales y cómo los efectos rastrean las dependencias. Esta guía crea un pequeño modelo funcional de los hooks y lo utiliza para explicar los comportamientos que confunden a las personas a diario: valores obsoletos después de setState, actualizaciones funcionales, limpieza de efectos y agrupamiento.
El estado no reside en la función
Tomemos el componente con estado más pequeño posible:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Es tentador pensar que count está dentro de Counter, pero React simplemente llama nuevamente a la función en cada renderizado:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Las variables locales desaparecen cuando una función devuelve, por lo que React mantiene el estado de los hooks fuera del componente, asociado a su Fiber, y devuelve el valor almacenado en cada renderizado.
Fiber almacena una secuencia de hooks
En forma simplificada, la relación se ve así:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
Un Fiber es una unidad de trabajo de React, creada tanto para componentes como para elementos anfitrión. Para un componente funcional, contiene los datos de los hooks, conceptualmente como una cadena:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
La implementación real tiene muchos más campos, pero esta imagen es suficiente para comprender el comportamiento básico.
Los hooks se corresponden por orden de llamada
Aquí hay un componente con tres hooks:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
React no tiene nombres para estos hooks; solo conoce al primero, al segundo y al tercero, y asume que llegan en esa secuencia en cada renderizado:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
Esa suposición es toda la base de las Reglas de los Ganchos.
¿Qué sale mal con un gancho condicional?
Ahora considere un gancho oculto detrás de una condición:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
En una renderización donde el usuario está conectado, se llaman dos ganchos:
loggedIn = true
Hook #1 → name
Hook #2 → count
Si loggedIn pasa a ser falso más tarde, solo se produce una llamada:
loggedIn = false
Hook #1 → count
La variable que antes almacenaba el nombre ahora recibe la llamada de conteo, por lo que el estado se asocia con el gancho incorrecto:
Before:
1 → name
2 → count
After:
1 → count
Por lo tanto, el orden de los ganchos nunca debe variar entre renderizaciones, lo que descarta las llamadas en lugares como estos:
if / else
loops
nested functions
event handlers
Manténgalos en el nivel superior de los componentes y los ganchos personalizados; la extensión de linting para React hooks lo impone.
Un useState simplificado en veinte líneas
Crear una versión mínima hace que el mecanismo sea concreto. El boceto a continuación mantiene un array de espacios para ganchos y un cursor. Cada llamada a useState reclama el espacio correspondiente al cursor, lo inicializa solo si está vacío, devuelve un setter vinculado a ese índice y avanza el cursor:
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const index = currentHook;
if (hooks[index] === undefined) {
hooks[index] = {
state: initialValue
};
}
function setState(value) {
hooks[index].state =
typeof value === "function"
? value(hooks[index].state)
: value;
render();
}
const state = hooks[index].state;
currentHook++;
return [state, setState];
}
Hay dos cosas que se implican pero no se muestran: render() debe restablecer currentHook a 0 antes de llamar nuevamente al componente, y debe ejecutarlo de nuevo. Dado un componente como este:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
el almacenamiento termina teniendo este aspecto:
hooks[0]
state = 0
hooks[1]
state = "Salim"
En la siguiente renderización, cada llamada encuentra su espacio nuevamente únicamente por posición.
Dónde termina el modelo simplificado
Real React agrega objetos de hook en Fibers, colas de actualización, un programador con carriles por prioridad, despachadores de montaje y actualización, lógica de reconciliación y confirmación; no existe un array global. La idea esencial sigue siendo la misma:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
Cómo funciona useState en React
Para esta llamada:
const [count, setCount] = useState(0);
React necesita dos cosas por cada hook de estado:
State
+
Update queue
En términos visuales, el hook almacena su valor actual en memoizedState además de una cola de actualizaciones pendientes:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
La primera renderización: montaje
Dada esta componente:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
la renderización inicial crea el hook, almacena el valor inicial, establece una cola vacía y devuelve la pareja correspondiente:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
La Fiber ahora se ve más o menos así, y esta fase se denomina montaje:
Fiber
│
└── Hook
├── state: 0
└── queue
Renderizaciones posteriores: actualización
Supongamos que un manejador de eventos llama a:
setCount(5);
React programa una actualización. Durante el siguiente renderizado, useState no crea nada; localiza el hook existente, aplica las actualizaciones en cola y devuelve el resultado:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
El 0 que se pasa es, por lo tanto, un valor inicial, no un reinicio. Solo importa cuando se crea el hook por primera vez.
setState es una solicitud, no una asignación
Una llamada como esta:
setCount(5);
no significa “establecer count en 5 ahora”. En realidad, agrega una solicitud que React maneja según su propio cronograma:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Las actualizaciones se procesan en orden
Considere tres actualizaciones funcionales seguidas:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React las mantiene en la cola, partiendo del valor actual:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
y las aplica una tras otra:
0 → 1
1 → 2
2 → 3
El resultado es 3, y por eso las actualizaciones funcionales son adecuadas para aquellas que dependen del valor anterior.
Por qué pasar un valor se comporta de manera diferente
Compare la versión que pasa valores:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Si el render actual tiene count igual a 0, las tres expresiones se evalúan antes de que se procese cualquier actualización, por lo que su resultado es:
setCount(1)
setCount(1)
setCount(1)
El estado final es 1. Con funciones de actualización:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
cada función recibe el resultado de la anterior, lo que da 0, 1, 2 y finalmente 3.
El estado es una instantánea de un render
Este manejo sorprende a muchos desarrolladores:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
El registro muestra 0, no 1. count es una constante que pertenece al render que creó el manejo; llamar al setter no lo modifica:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Las cierres explican la instantánea
Este comportamiento es una simple semántica de cierre en JavaScript. Mire:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Cada renderización define un nuevo logCount, y cada uno captura el count de su propia renderización:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
Un callback antiguo siempre ve los valores de su propia renderización, lo que es la causa principal de los errores por valores obsoletos.
Cómo useEffect recuerda sus dependencias
Tomemos un efecto básico:
useEffect(() => {
console.log(count);
}, [count]);
React debe hacer seguimiento de tres cosas para ello:
Effect function
Dependencies
Cleanup function
Internamente, estas se almacenan en el gancho del efecto, con nombres similares a estos:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
En cada renderización, React compara la nueva matriz de dependencias con la almacenada.
Comparación de dependencias
Usando el mismo efecto:
useEffect(() => {
console.log(count);
}, [count]);
la primera renderización registra:
count = 0
deps = [0]
y la siguiente renderización produce:
count = 1
deps = [1]
React compara los arrays elemento por elemento:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Algo cambió, por lo que se ejecuta el efecto. Si un render posterior produce los mismos valores:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
se omite el efecto. Cada elemento se compara con Object.is, lo que significa que un objeto o array recreado durante el render se considera cambiado cada vez.
Limpieza
Un efecto puede devolver una función que anule su trabajo:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Esa función devuelta es la de limpieza. El ciclo de vida del intervalo se ve así:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
Cuando las dependencias pueden cambiar, la limpieza es aún más importante:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
Con cada cambio en userId, React desmonta el efecto anterior antes de iniciar el nuevo, y ejecuta la limpieza final cuando el componente se desmonta:
Old effect
↓
Cleanup
↓
New effect
Los efectos se sincronizan, no son métodos de ciclo de vida
Un atajo común es tratar useEffect con un array vacío como si fuera componentDidMount. Una forma más útil de entenderlo es que un efecto mantiene a React sincronizado con algún sistema externo a React, como por ejemplo:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
Por ejemplo, una conexión de chat:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Cuando roomId cambia, el efecto abandona la sala antigua y se une a la nueva, de modo que la conexión siempre coincide con las props actuales.
useRef: un objeto estable
Un ref se crea de esta manera:
const ref = useRef(0);
React devuelve exactamente el mismo objeto en cada renderizado:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
Como resultado, al asignarle algo:
ref.current = 100;
no se provoca un renderizado, a diferencia de una actualización de estado:
setCount(100);
que sí programa uno. En resumen, la diferencia es:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
Un uso típico es almacenar un ID de temporizador:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
El ID debe persistir pero nunca aparecer en la pantalla; esa es la función típica de un referente.
useMemo y useCallback
Un cálculo memorizado se ve así:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
El hook almacena conceptualmente el último resultado y las dependencias utilizadas para calcularlo:
Hook
├── memoizedValue
└── dependencies
En la primera renderización, se ejecuta el cálculo:
data = A
calculate(A)
↓
result = X
Si la siguiente renderización pasa los mismos datos:
data = A
las dependencias no cambian:
A → A
por lo que se devuelve el resultado almacenado X. Cuando data pasa a ser B, se vuelve a ejecutar el cálculo.
Considérelo como una optimización, no como una promesa: React puede descartar la caché, por lo que el código debe seguir siendo correcto si se vuelve a calcular el valor. Úselo para tareas costosas o cuando sea importante tener un referente estable, no por defecto.
useCallback aplica la misma idea a las funciones:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
Almacena la función y sus dependencias:
Hook
├── memoized function
└── dependencies
Las dependencias sin cambios devuelven la misma referencia de función; las que cambian devuelven una nueva. Esto es importante sobre todo cuando la función de callback se dirige a un componente hijo envuelto en React.memo, ya que una nueva referencia anularía el efecto de la memorización:
const Child = React.memo(ChildComponent);
Los hooks personalizados son simplemente funciones
Aquí hay un pequeño hook personalizado:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
utilizado por un componente:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
Un hook personalizado no es un componente separado. Es una función sencilla creada a partir de otros hooks, y su useState se adjunta al componente que se está rendering en ese momento:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Por lo tanto, dos componentes que llaman a useCounter obtienen dos contadores independientes.
Disparadores: una API, dos comportamientos
Cuando se llama a useState, la función pública redirige la solicitud al despachador que React tiene instalado para la renderización actual:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
En la primera renderización es la implementación de montaje:
Mount
↓
mountState()
y en las renderizaciones posteriores es la implementación de actualización:
Update
↓
updateState()
Montaje versus actualización para un hook
Para esta llamada:
const [count, setCount] = useState(0);
montaje crea las estructuras necesarias:
Create Hook
↓
Store initial state
↓
Create update queue
mientras que actualización las reutiliza:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
En resumen:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Fase de renderización y fase de confirmación
Los hooks tienen más sentido dentro del modelo de renderización en dos fases de React:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
Durante la renderización, React llama a los componentes y calcula la siguiente interfaz de usuario:
Components execute
Hooks are processed
New UI is calculated
Durante la fase de confirmación, aplica el resultado obtenido:
DOM mutations happen
Refs are updated
Layout effects are handled
React moderno puede pausar, reiniciar o descartar el trabajo de renderizado, mientras que el commit siempre se completa, por lo que la lógica de renderizado debe ser pura.
Mantener el renderizado puro
Evite los efectos secundarios directamente en el cuerpo del componente:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
El renderizado puede ejecutarse repetidamente o descartarse antes del commit, por lo que esta solicitud podría activarse muchas veces. Utilice uno de estos en su lugar:
Event handler
useEffect
Framework data-loading mechanism
El renderizado solo debe calcular la interfaz de usuario a partir de las entradas:
UI = f(props, state)
Uniendo todo: de principio a fin con un clic
Aquí hay un componente que utiliza estado y un efecto:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Un clic pasa por todas las etapas descritas anteriormente:
Click
↓
setCount()
↓
Update added to Hook queue
↓
React schedules work
↓
Counter renders
↓
useState retrieves/processes state
↓
useEffect checks dependencies
↓
React reconciles new UI
↓
Commit DOM changes
↓
Effect runs
Batching y por qué el DOM no se actualiza de inmediato
Un manejador que establece varios valores de estado:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
No provoca tres renderizaciones separadas. Desde React 18, las actualizaciones se agrupan automáticamente, incluidas aquellas en tiempos de espera y promesas:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
en lugar de:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
El agrupamiento en sí mismo es una forma de evitar trabajos innecesarios.
El mismo razonamiento explica por qué una llamada como:
setCount(10);
no significa necesariamente de inmediato:
state = 10
DOM = 10
En lugar de eso, fluye a través del pipeline:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
El aplazamiento da a React la posibilidad de agrupar, priorizar y omitir trabajos redundantes; flushSync de react-dom fuerza una actualización síncrona cuando realmente es necesario.
El modelo completo de un vistazo
Todo lo anterior encaja en una sola imagen, desde el componente hasta los píxeles en la pantalla:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Puntos clave
- El estado del hook persiste en la Fiber del componente y se asocia a cada llamada según su posición, por lo que el orden de los hooks nunca debe cambiar.
setStateagrega una actualización en la cola; el nuevo valor aparece en la siguiente renderización, mientras que la renderización actual mantiene su instantánea.- Utilice actualizaciones funcionales siempre que una actualización dependa del valor anterior.
- Los efectos se sincronizan con sistemas externos; las dependencias se comparan con
Object.is, y la limpieza se ejecuta antes de volver a ejecutarse y al desmontar el componente. - Las refs son cajas mutables estables que nunca provocan renderizaciones; la memorización es una optimización, no una garantía.
- Los hooks personalizados comparten lógica, no estado, y la renderización debe mantenerse pura porque React puede repetirla o descartarla.
Lecturas relacionadas
- Escribiendo hooks de React: useState, useEffect, useReducer y hooks personalizados — Aprenda cómo escribir correctamente useState, useEffect, useReducer y hooks personalizados en TypeScript, además de cuándo elegir TypeScript en lugar de JavaScript puro realmente compensa.
- Eliminando el ref lastest-value: Eventos de efecto con useEffectEvent — Aprenda cómo useEffectEvent en React 19.2 separa la lógica no reactiva de un efecto, elimina los trucos con el ref lastest-value y cuándo este hook es la herramienta inadecuada.