Módulo 3: State With Usestate
El updater funcional: `setX(x => ...)`
Descripción
En la lección 4 mediste un comportamiento que parecía imposible: dos setCount(count + 1) seguidas suman 1, no 2, porque count es un snapshot fijo dentro del render. Ahí quedó una pregunta abierta: entonces, ¿cómo sumo 2 de verdad? ¿Cómo hago que el nuevo valor se base en el anterior cuando el anterior está congelado en la foto? Esta lección responde con la herramienta que React diseñó exactamente para eso: el updater funcional.
La idea es un cambio pequeño de sintaxis con una consecuencia grande. En vez de pasarle al setter un valor, le pasas una función:
setCount(c => c + 1); // pasa una funcion, no un valor
Cuando le das una función al setter, React no la usa para reemplazar el estado de golpe; la aplica sobre el valor pendiente. Es decir: cuando llegue el momento de procesar los cambios, React toma el valor que hay en la cola en ese punto (no el snapshot del render), le pasa tu función, y usa lo que devuelva. Si encolas dos updaters c => c + 1, el primero recibe el valor base y devuelve base+1; el segundo recibe ese resultado y devuelve base+2. Se acumulan, porque cada uno ve el resultado del anterior, no la foto congelada.
Esa es toda la diferencia. setCount(count + 1) dice "pon el estado en (el snapshot) + 1" —y dos de esos ponen lo mismo—. setCount(c => c + 1) dice "toma lo que haya pendiente y súmale 1" —y dos de esos se encadenan—. Un valor reemplaza; una función transforma lo pendiente.
Conexión con el módulo. Esta lección cierra el arco que abrieron la 4 y la 5. El snapshot (4) explicó por qué setX(x + 1) repetido no acumula; el re-render (5) explicó cuándo se aplica el cambio (en el próximo render); y el updater (esta) da la solución para cuando necesitas basarte en el valor anterior. Es la regla práctica que memorizarás: si el nuevo estado depende del anterior, usa la forma con función. Y de paso introduce el batching —que React agrupa varios sets de un mismo evento en un solo re-render—, que es la razón de que el snapshot dure "todo el handler" y no solo una línea.
Una analogía: la orden fija contra la instrucción "súmale uno a la cuenta"
Imagina que estás en una cafetería con una cuenta abierta, y le dejas notas al barista para que las procese todas juntas al final. Hay dos formas de escribir una nota.
Forma 1 — el valor fijo. Miras la cuenta ahora (dice $3) y escribes: "deja la cuenta en $4". Luego, sin volver a mirar, escribes otra: "deja la cuenta en $4". Cuando el barista procesa tus dos notas, la primera pone $4 y la segunda... vuelve a poner $4. La cuenta queda en $4, no en $5. Tus dos notas leyeron el mismo momento (la cuenta en $3) y calcularon el mismo destino ($4). Esto es setCount(count + 1) dos veces: los dos leen el snapshot y agendan el mismo valor.
Forma 2 — la instrucción relativa. En vez de un monto fijo, escribes una instrucción: "súmale $1 a la cuenta, sea cual sea". Y otra igual: "súmale $1 a la cuenta". Ahora el barista, al procesarlas en orden, hace: parte de $3, aplica la primera (→ $4), aplica la segunda sobre ese resultado (→ $5). La cuenta queda en $5. Cada instrucción actúa sobre lo que dejó la anterior, no sobre el momento en que la escribiste. Esto es setCount(c => c + 1) dos veces: cada función transforma el valor pendiente, y se acumulan.
La diferencia es "dictar un destino" contra "dictar una operación". Un destino fijo ignora lo que otras notas hayan hecho; una operación relativa se encadena con ellas. Cuando lo que quieres depende del valor anterior —"uno más que lo que haya"—, necesitas la instrucción relativa: el updater funcional. Guarda las dos notas: "deja la cuenta en X" (valor) contra "súmale uno a la cuenta" (función).
Ejemplo trabajado: count + 1 x2 da 1, c => c + 1 x2 da 2
Vamos a medir las dos formas lado a lado, con la traza de cómo cada actualización transforma el valor pendiente. Modelamos la cola de React, que ahora puede contener valores (reemplazan) o funciones (transforman lo pendiente).
Así se ve en React de verdad, el mismo botón "+2" de la lección 4, ahora arreglado:
import { useState } from 'react';
function CartBadge() {
const [count, setCount] = useState(0);
function handleAddTwoWrong() {
setCount(count + 1); // agenda el VALOR 1 (snapshot + 1)
setCount(count + 1); // agenda el VALOR 1 otra vez -> total: 1
}
function handleAddTwoRight() {
setCount(c => c + 1); // agenda la FUNCION "suma 1 a lo pendiente"
setCount(c => c + 1); // agenda otra "suma 1 a lo pendiente" -> total: 2
}
return <span>Cart ({count})</span>;
}
Y la versión ejecutable en Node. processQueue recorre la cola: si el elemento es una función, la aplica sobre el estado pendiente; si es un valor, reemplaza. Registramos la traza de cada paso:
'use strict';
// ── El updater funcional: setCount(c => c + 1) ──
// La cola de actualizaciones puede llevar VALORES o FUNCIONES:
// - un VALOR reemplaza el estado.
// - una FUNCION recibe el estado pendiente y devuelve el nuevo (se acumula).
function processQueue(base, queue) {
let state = base;
const trace = [];
for (const update of queue) {
const prev = state;
state = typeof update === 'function' ? update(state) : update;
trace.push(`${JSON.stringify(prev)} -> ${JSON.stringify(state)}`);
}
return { state, trace };
}
const count = 0; // el snapshot de este render
// A) setCount(count + 1) dos veces -> agenda [1, 1] (valores)
const queueA = [count + 1, count + 1];
const a = processQueue(count, queueA);
console.log('A) setCount(count + 1) x2 (pasa VALORES)');
console.log(' cola agendada:', JSON.stringify(queueA));
console.log(' traza:', a.trace.join(' | '));
console.log(' resultado:', a.state);
// B) setCount(c => c + 1) dos veces -> agenda [fn, fn] (funciones)
const inc = (c) => c + 1;
const queueB = [inc, inc];
const b = processQueue(count, queueB);
console.log('\nB) setCount(c => c + 1) x2 (pasa FUNCIONES)');
console.log(' cola agendada:', queueB.map((f) => f.toString()));
console.log(' traza:', b.trace.join(' | '));
console.log(' resultado:', b.state);
// C) Tres veces con updater: +3
const c3 = processQueue(count, [inc, inc, inc]);
console.log('\nC) setCount(c => c + 1) x3 -> resultado:', c3.state);
// D) Mezcla: un valor RESETEA la acumulacion previa
const d = processQueue(count, [inc, 10, inc]);
console.log('\nD) [c=>c+1, 10, c=>c+1] (un valor pisa lo agendado)');
console.log(' traza:', d.trace.join(' | '));
console.log(' resultado:', d.state);
Qué esperar. Al correr el archivo, la salida es exactamente esta:
A) setCount(count + 1) x2 (pasa VALORES)
cola agendada: [1,1]
traza: 0 -> 1 | 1 -> 1
resultado: 1
B) setCount(c => c + 1) x2 (pasa FUNCIONES)
cola agendada: [ '(c) => c + 1', '(c) => c + 1' ]
traza: 0 -> 1 | 1 -> 2
resultado: 2
C) setCount(c => c + 1) x3 -> resultado: 3
D) [c=>c+1, 10, c=>c+1] (un valor pisa lo agendado)
traza: 0 -> 1 | 1 -> 10 | 10 -> 11
resultado: 11
Esta salida es la lección entera, medida. Compara A y B, que son el mismo objetivo ("sumar 2") con las dos formas.
A) Valores: resultado 1. La cola es [1, 1] —los dos setCount(count + 1) calcularon 0 + 1 = 1 con el snapshot fijo—. Mira la traza: 0 -> 1 (el primer valor reemplaza, deja 1) y luego 1 -> 1 (el segundo valor también es 1, reemplaza, deja 1). El segundo 1 pisa al primero sin sumar nada. Resultado: 1. Es exactamente el "misterio" de la lección 4, ahora con la traza a la vista: dos reemplazos con el mismo valor dan ese valor.
B) Funciones: resultado 2. La cola es [fn, fn] —dos veces la función c => c + 1—. Mira la traza: 0 -> 1 (la primera función recibe el 0 pendiente y devuelve 1) y luego 1 -> 2 (la segunda función recibe el 1 que dejó la primera, no el snapshot, y devuelve 2). Cada updater ve el resultado del anterior, así que se encadenan: 0 → 1 → 2. Resultado: 2. Ese es el fix: la función se aplica sobre lo pendiente, no sobre la foto.
C) Tres updaters: resultado 3. Confirma que se acumulan sin límite: 0 → 1 → 2 → 3. Tres "súmale uno a lo pendiente" desde 0 dan 3. (Con valores, tres setCount(count + 1) habrían dado 1, como viste en la lección 4.)
D) La mezcla revela cómo se combinan. La cola [c=>c+1, 10, c=>c+1] produce la traza 0 -> 1 (la función suma), 1 -> 10 (¡un valor reemplaza, tira el 1 y pone 10!), 10 -> 11 (la función suma sobre el 10). Resultado: 11. Un valor en la cola descarta lo acumulado y planta su número; una función que venga después opera sobre ese número. Esto explica la regla: un setCount(valor) es un "reset"; un setCount(fn) es un "ajuste relativo".
El batching: por qué el snapshot dura "todo el handler"
Quizás te preguntas: si cada set dispara un re-render (lección 5), ¿por qué los dos sets del handler comparten el mismo snapshot en vez de que el primero re-renderice y el segundo vea el valor nuevo? La respuesta es el batching (agrupamiento). React no re-renderiza después de cada setCount; espera a que termine el handler completo, junta todos los sets que hiciste, procesa la cola de una vez, y hace un solo re-render con el resultado final. Por eso los dos sets del mismo evento ven el mismo snapshot: React aún no ha re-renderizado entre ellos; ambos ocurren "dentro" del mismo render.
El batching es una optimización valiosa: si un handler hace cinco cambios de estado, no quieres cinco re-renders (cinco repintados parciales); quieres uno solo con el resultado de los cinco. React lo agrupa por ti. La consecuencia práctica es justo lo que estás aprendiendo: dentro de un handler, la variable de estado es una foto fija (snapshot), y si necesitas encadenar cambios sobre el valor anterior, usas el updater funcional para que se apliquen en orden sobre lo pendiente.
Dibujado, el batching de un handler con dos updaters:
flowchart TD
H["handler corre: count = 0 (snapshot)"] --> S1["setCount(c => c+1) -> cola: [inc]"]
S1 --> S2["setCount(c => c+1) -> cola: [inc, inc]"]
S2 --> B["handler termina: React procesa la cola juntos"]
B --> P["0 -> 1 -> 2"]
P --> R["UN solo re-render: count = 2"]
Un handler, varios sets, una cola, un re-render. Eso es batching.
Cuándo usar cada forma
La regla es simple:
- Usa el updater funcional (
setX(x => ...)) cuando el nuevo valor depende del anterior. Incrementar (c => c + 1), alternar (b => !b), agregar a una lista (arr => [...arr, item]). Es más seguro: funciona aunque haya varios sets en el mismo evento, y aunque el estado ya haya cambiado por otra razón entre que se planeó el cambio y se aplicó. - Usa el valor directo (
setX(nuevo)) cuando el nuevo valor no depende del anterior. Fijar un texto (setQuery('mouse')), poner un booleano concreto (setExpanded(true)), resetear (setCount(0)). Aquí no hay nada del valor viejo que preservar, y el valor directo es más claro.
En la duda, el updater nunca está de más si el valor depende del anterior; y el valor directo es lo natural cuando el nuevo estado es independiente.
Errores comunes
Usar el valor directo cuando el nuevo depende del anterior. Qué pasa: se escribe setCount(count + 1) en situaciones donde puede haber varios incrementos seguidos (un bucle, varios handlers, un evento rápido), y se pierden incrementos. Por qué pasa: setCount(count + 1) "parece" correcto y funciona cuando hay un solo set por render. Cómo detectarlo: los incrementos no cuadran (sumaste tres veces y subió uno). Cómo corregirlo: cuando el nuevo valor se basa en el anterior, usa setCount(c => c + 1). Es la forma robusta: acumula bien aunque haya varios sets en el mismo evento. La regla mecánica: si en tu setX(...) aparece la variable de estado (count, items), probablemente quieres la forma con función (setX(c => ...)).
Poner efectos secundarios dentro del updater. Qué pasa: se mete un console.log, una llamada a API, o una mutación dentro de la función updater: setCount(c => { fetch(...); return c + 1; }). Por qué pasa: es un lugar cómodo que "recibe el valor". Cómo detectarlo: tu updater hace algo más que calcular y devolver el nuevo estado. Cómo corregirlo: el updater debe ser una función pura: recibe el estado pendiente, devuelve el nuevo, y nada más. React puede llamarlo más de una vez (por ejemplo, en modo estricto de desarrollo, para detectar impurezas), así que cualquier efecto secundario adentro se ejecutaría de forma impredecible. Los efectos van en handlers o en useEffect (módulo 6), no dentro del updater.
Olvidar que el updater recibe el estado pendiente, no el snapshot. Qué pasa: alguien nombra el parámetro igual que la variable de estado (setCount(count => count + 1)) y se confunde pensando que "es el mismo count de arriba". Por qué pasa: el sombreado de nombres despista. Cómo detectarlo: crees que el parámetro del updater es el snapshot, cuando es el valor pendiente en la cola (que puede diferir del snapshot si hubo sets antes). Cómo corregirlo: nombra el parámetro distinto para que quede claro que es otro valor: setCount(c => c + 1), setItems(prev => [...prev, item]). Ese c o prev es lo que React tiene pendiente en ese punto de la cola —el resultado de los updaters anteriores—, no la variable count congelada del render. Esa es justamente su gracia.
Ejercicios
Ejercicio 1 — Predice las dos formas. Partiendo de count = 4, di el resultado de cada handler:
// (a)
setCount(count + 2);
setCount(count + 2);
// (b)
setCount(c => c + 2);
setCount(c => c + 2);
// (c)
setCount(c => c + 1);
setCount(10);
setCount(c => c + 1);
Ver solución
Con count = 4 (snapshot):
- (a) Los dos
setCount(count + 2)calculan4 + 2 = 6y agendan[6, 6]. Cada valor reemplaza. Resultado: 6, no 8. - (b) Los dos updaters se encadenan sobre el valor pendiente:
4 → 6 → 8. Resultado: 8. Cadac => c + 2ve el resultado del anterior. - (c) La cola es
[fn, 10, fn]. Se procesa:4 → 5(la primera función suma 1),5 → 10(el valor 10 reemplaza, tira el 5),10 → 11(la segunda función suma 1 sobre el 10). Resultado: 11. Un valor en medio descarta lo acumulado y reinicia desde su número.
La lección de (c): un valor directo es un "reset" que ignora lo pendiente; un updater ajusta lo que haya. El orden en la cola manda.
Ejercicio 2 — Arregla el toggle rápido. Este botón "alterna" un booleano liked (me gusta / no me gusta), pero si el usuario da dos clics muy rápidos que caen en el mismo lote, no se comporta bien. Explica el riesgo con la forma de valor y reescríbelo con updater:
setLiked(!liked);
Ver solución
El riesgo con la forma de valor: setLiked(!liked) usa el snapshot de liked. Si por alguna razón hay dos de estos en el mismo lote (dos sets encolados antes de un re-render), ambos leen el mismo liked del snapshot y agendan el mismo valor negado. Por ejemplo, si liked era false, los dos agendan !false = true, y el resultado es true —no vuelve a false—. Dos alternancias deberían dejarlo como estaba; con la forma de valor, se pierde una.
Reescrito con updater:
setLiked(prev => !prev);
Ahora cada alternancia opera sobre el valor pendiente: false → true → false. La primera niega el false (→ true); la segunda niega ese true (→ false). Dos clics dejan el estado como empezó, que es lo correcto para un toggle. La regla en acción: como el nuevo valor (!prev) depende del anterior, va con función.
Ejercicio 3 — Explica el batching. Un compañero dice: "si cada setCount dispara un re-render, ¿por qué cuando hago tres setCount en un handler no veo tres renders en pantalla, solo el resultado final?". Explícale qué es el batching y por qué es deseable, y cómo se relaciona con el snapshot.
Ver solución
Lo que ocurre es el batching (agrupamiento). React no re-renderiza inmediatamente después de cada setCount. Espera a que termine todo el handler, junta los tres sets que hiciste, procesa la cola de actualizaciones de una vez, y hace un solo re-render con el resultado final. Por eso no ves tres repintados intermedios; ves solo el estado final.
Por qué es deseable: si cada set repintara de inmediato, un handler que cambia cinco piezas de estado provocaría cinco renders (cinco recálculos y cinco commits parciales), con parpadeos y desperdicio. El batching los agrupa en uno solo: React aplica todos los cambios juntos y repinta una vez. Es más rápido y evita estados intermedios visibles.
Cómo se relaciona con el snapshot: precisamente porque React no re-renderiza entre los sets de un handler, la variable de estado sigue siendo la misma foto (el snapshot) durante todo el handler. Los tres setCount(count + 1) ven el mismo count porque React aún no ha re-renderizado. Y por eso, para encadenar cambios que dependen del anterior dentro de un lote, existe el updater funcional: como los sets se procesan juntos al final, la función es la única forma de que cada uno vea el resultado del anterior en vez del snapshot.
Resumen y siguiente paso
En esta lección aprendiste la solución al problema del snapshot: el updater funcional, setX(x => ...). Cuando le pasas una función al setter en vez de un valor, React la aplica sobre el valor pendiente (no sobre el snapshot), así que varios updaters se encadenan. Lo mediste lado a lado: setCount(count + 1) x2 dio 1 (dos valores que reemplazan con lo mismo: 0 -> 1 -> 1), mientras setCount(c => c + 1) x2 dio 2 (dos funciones que acumulan: 0 -> 1 -> 2); y viste que un valor en la cola descarta lo acumulado ([inc, 10, inc] da 11). Entendiste el batching —React agrupa los sets de un handler en un solo re-render, que es por qué el snapshot dura todo el handler— y la regla de cuándo usar cada forma (función si el nuevo valor depende del anterior; valor directo si no).
Antes de avanzar deberías poder: escribir un updater setX(x => ...) y explicar qué recibe (el valor pendiente, no el snapshot); predecir el resultado de mezclas de valores y funciones en una cola; decidir cuándo usar la forma con función y cuándo el valor directo; y explicar el batching y su relación con el snapshot.
La lección 7 cierra el modelo del estado con la última regla que falta, y una que atrapa a todos: no mutes el estado. Vas a ver, ejecutado, que React decide si re-renderiza comparando el estado nuevo con el viejo por referencia (Object.is), y que por eso una mutación (cart.push(...), product.inStock = false) deja la misma referencia y pasa inadvertida —React "no la ve" y no repinta—. La solución es crear un objeto o array nuevo ([...cart, item], {...product, inStock: false}), y de paso consolidarás "una pieza de estado por cosa que cambia".
Recursos
- React, "Queueing a Series of State Updates" — react.dev/learn/queueing-a-series-of-state-updates. La página oficial de esta lección: cómo React encola valores y funciones, por qué el updater acumula, y el batching. La referencia central. En inglés.
- React, "useState" (referencia) — react.dev/reference/react/useState. La sección sobre la función setter y su forma con updater: cuándo pasar una función y por qué debe ser pura. En inglés.
- React, "State as a Snapshot" — react.dev/learn/state-as-a-snapshot. El porqué del problema que el updater resuelve: la variable de estado es una foto fija del render. En inglés.
- MDN, "Arrow function expressions" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions. La sintaxis
c => c + 1que usa el updater: qué es una función flecha y cómo se lee. En inglés.