Módulo 3: State With Usestate
Mini-proyecto: dale estado al storefront
Descripción
Es hora de juntar las seis piezas del módulo en un solo lugar. En el módulo 1 armaste el storefront estático de Mercado: App, SearchBar, ProductList, ProductCard, Cart, CartItem —todo correcto, pero muerto: la barra de búsqueda no guardaba lo que escribías, las tarjetas no se expandían, el carrito no contaba nada—. En este mini-proyecto le das vida: agregas estado real a esos componentes y ves la interfaz reaccionar.
Vas a aplicar, a la vez, todo lo del módulo: declararás estado con useState (lección 3), distinguiendo lo que es estado de lo que es props (lección 2); actualizarás ese estado con setters entendiendo que es un snapshot (lección 4) y que cambiarlo dispara un re-render (lección 5); usarás el updater funcional cuando el nuevo valor dependa del anterior (lección 6); y respetarás la inmutabilidad al tocar objetos y arrays (lección 7). El entregable son dos cosas: el código React real con useState de los componentes con estado, y la lógica ejecutada en Node que demuestra que las piezas encajan —una secuencia de interacciones con su traza de renders, la trampa del snapshot con su fix, y una actualización inmutable—.
Conexión con el módulo. Esta es la lección de integración: cada concepto que viste aislado aparece aquí trabajando junto a los demás, que es como se usan de verdad. Y marca la frontera con lo que sigue. Aquí los setters se llaman con handlers simples o "a mano" en el modelo ejecutado; el cableado real entre la acción del usuario (teclear, hacer clic) y el setter son los eventos del módulo 4. El estado aquí vive local a cada componente; compartirlo entre componentes —el carrito que App posee y Cart muestra— es levantar el estado, el módulo 7. Y filtrar la lista por el query es estado derivado, el módulo 5. Este proyecto es "estado local, bien hecho"; los tres módulos siguientes lo extienden.
Qué vas a construir
Tres piezas de estado, cada una ilustrando una parte del módulo:
- La
SearchBarcon estadoquery. El texto que el usuario escribe, guardado en la libreta de la barra. Cambia con cada tecla; por ahora solo lo guardamos y mostramos (filtrar la lista es el módulo 5). - El
ProductCardcon estadoexpanded. Un toggle booleano: mostrar los detalles largos del producto o solo lo esencial. Ilustra la máquina de estado (colapsado ↔ expandido) y el updater para alternar (e => !e). - Un contador del carrito con estado
count. El número de productos agregados. Ilustra el snapshot (por qué dossetCount(count + 1)suman 1) y su fix con el updater.
Cada pieza es local a su componente: el query es de la SearchBar, el expanded es de cada ProductCard, el count del contador. Ninguno se comparte todavía.
El código React real
Así se escribe el storefront con estado, en React de verdad. Estúdialo pieza por pieza; es la síntesis del módulo.
La SearchBar recibe una prop (placeholder, del padre) y tiene un estado (query, suyo):
import { useState } from 'react';
function SearchBar(props) {
const [query, setQuery] = useState(''); // ESTADO: propio, arranca vacio
return (
<input
className="search-bar"
placeholder={props.placeholder} // PROP: del padre, de solo lectura
value={query} // ESTADO: lo que el usuario escribio
// el onChange que llama a setQuery(e.target.value) es el modulo 4
/>
);
}
El ProductCard recibe el producto por props y tiene un toggle expanded en estado. Para alternar, usa el updater (e => !e), porque el nuevo valor depende del anterior:
function ProductCard(props) {
const [expanded, setExpanded] = useState(false); // ESTADO: propio de esta tarjeta
const details = expanded
? `${props.name} | ${formatPrice(props.priceCents)} | ${props.description}`
: `${props.name} | ${formatPrice(props.priceCents)}`;
return (
<article className="product-card">
<p>{details}</p>
{/* al dar clic: setExpanded(e => !e) — el evento es el modulo 4 */}
</article>
);
}
El contador del carrito, con el updater para acumular bien:
function CartBadge() {
const [count, setCount] = useState(0);
function addOne() {
setCount(c => c + 1); // updater: se basa en el valor anterior (leccion 6)
}
return <span className="cart-badge">Cart ({count})</span>;
}
Nota tres cosas que resumen el módulo. Primero: cada estado se declara con useState(inicial) en el tope del componente. Segundo: los estados son locales —el expanded de una tarjeta no afecta a otra, porque cada ProductCard tiene su propia celda—. Tercero: donde el nuevo valor depende del anterior (e => !e, c => c + 1), usamos el updater; donde no (setQuery(nuevoTexto)), el valor directo.
Ejemplo trabajado: el storefront cobra vida, ejecutado
Vamos a ejecutar la lógica del estado para comprobar que las piezas encajan. Modelamos el mini-runtime de hooks con varias piezas de estado (una celda por useState, en orden) y con un setter que acepta valor o updater. Corremos una secuencia de interacciones sobre una SearchBar con dos estados, y luego demostramos la trampa del snapshot con su fix y una actualización inmutable.
'use strict';
// ── Mini-runtime de hooks: soporta VARIAS piezas de estado por componente
// (una celda por cada useState, en orden). ──
let cells = [];
let cursor = 0;
let renderApp = () => {};
let renderCount = 0;
function useState(initial) {
const i = cursor++;
if (!(i in cells)) cells[i] = initial;
const setState = (updater) => {
// acepta un valor O una funcion (updater funcional)
cells[i] = typeof updater === 'function' ? updater(cells[i]) : updater;
renderApp();
};
return [cells[i], setState];
}
// f: un SearchBar con DOS piezas de estado: el texto y su longitud mostrada.
let ui;
function SearchBar() {
const [query, setQuery] = useState(''); // celda 0
const [submits, setSubmits] = useState(0); // celda 1
renderCount++;
console.log(`render #${renderCount}: query=${JSON.stringify(query)} submits=${submits}`);
return { setQuery, setSubmits };
}
function render() {
cursor = 0;
ui = SearchBar();
}
renderApp = render;
console.log('=== el storefront cobra vida: una secuencia de interacciones ===');
render(); // montaje
ui.setQuery('mo'); // el usuario escribe
ui.setQuery('mouse');
ui.setSubmits((n) => n + 1); // envia la busqueda (updater funcional)
ui.setQuery(''); // limpia el campo
console.log('\n=== la trampa del snapshot y su fix, medidos ===');
// Sin updater: dos setSubmits(submits + 1) en el mismo handler suman 1.
const submitsSnapshot = 3;
const q1 = [submitsSnapshot + 1, submitsSnapshot + 1];
let s1 = submitsSnapshot;
for (const v of q1) s1 = v;
console.log('sin updater: setSubmits(submits+1) x2 desde submits=3 ->', s1);
// Con updater: dos setSubmits(n => n + 1) suman 2.
const q2 = [(n) => n + 1, (n) => n + 1];
let s2 = submitsSnapshot;
for (const f of q2) s2 = f(s2);
console.log('con updater: setSubmits(n=>n+1) x2 desde submits=3 ->', s2);
console.log('\n=== inmutabilidad: filtrar sin mutar el estado ===');
// Un "favoritos" que agrega ids sin mutar el array anterior.
const favs = ['p1'];
const nextFavs = [...favs, 'p2']; // copia nueva
console.log('favs originales:', JSON.stringify(favs), '(intacto)');
console.log('nuevo estado: ', JSON.stringify(nextFavs));
console.log('misma referencia?', Object.is(favs, nextFavs), '(por eso React ve el cambio)');
Qué esperar. Al correr el archivo, la salida es exactamente esta:
=== el storefront cobra vida: una secuencia de interacciones ===
render #1: query="" submits=0
render #2: query="mo" submits=0
render #3: query="mouse" submits=0
render #4: query="mouse" submits=1
render #5: query="" submits=1
=== la trampa del snapshot y su fix, medidos ===
sin updater: setSubmits(submits+1) x2 desde submits=3 -> 4
con updater: setSubmits(n=>n+1) x2 desde submits=3 -> 5
=== inmutabilidad: filtrar sin mutar el estado ===
favs originales: ["p1"] (intacto)
nuevo estado: ["p1","p2"]
misma referencia? false (por eso React ve el cambio)
Esta salida es el módulo entero funcionando junto. Vamos por sus tres partes.
La secuencia de interacciones (renders #1 a #5) muestra las dos piezas de estado conviviendo y el re-render en cada cambio. En el montaje (#1), ambas arrancan en su inicial: query="", submits=0. Luego el usuario escribe: setQuery('mo') dispara el render #2 (query="mo", y fíjate que submits sigue en 0 —cambiar una pieza de estado no toca las otras—). setQuery('mouse') → render #3. Después setSubmits(n => n + 1) → render #4: ahora submits=1 y query conserva su "mouse" (de nuevo, cada celda es independiente). Finalmente setQuery('') limpia el campo → render #5: query="", submits=1. Cinco cambios, cinco renders (más el montaje inicial fue el #1). Cada estado vive en su propia celda (celda 0 para query, celda 1 para submits), y actualizar una re-renderiza el componente sin perturbar la otra. Eso es "una pieza de estado por cosa que cambia" en acción.
La trampa del snapshot y su fix confirma, con números, la lección 4 y la 6, aplicadas al submits. Partiendo de submits = 3: dos setSubmits(submits + 1) sin updater dan 4 —los dos leen el snapshot 3 y agendan 4, que se reemplaza consigo mismo, sumando solo una vez—. Los mismos dos cambios con updater, setSubmits(n => n + 1), dan 5 —cada función se aplica sobre el valor pendiente: 3 → 4 → 5, sumando dos veces—. Es la diferencia entre "deja el contador en 4" (repetido) y "súmale uno a lo que haya" (encadenado). Si tu handler de "enviar búsqueda" incrementara dos veces, el updater es lo que hace que cuente bien.
La inmutabilidad cierra con la lección 7 aplicada a un estado de "favoritos". Agregar 'p2' con [...favs, 'p2'] deja favs intacto (["p1"]) y produce un estado nuevo (["p1","p2"]). Object.is(favs, nextFavs) da false: son referencias distintas, y por eso React vería el cambio y re-renderizaría. Si en vez de eso hubiéramos hecho favs.push('p2'), el array habría cambiado por dentro pero seguiría siendo la misma referencia, y React no se enteraría. Construir nuevo, no mutar: la regla que hace visible cada cambio.
El árbol del storefront, ahora con estado
Con el estado agregado, el árbol de componentes del módulo 1 gana "memoria" en tres puntos. Marcamos con [estado] dónde vive cada pieza local:
flowchart TD
App["App"] --> SB["SearchBar<br/>[estado: query]"]
App --> PL["ProductList"]
App --> Cart["Cart / CartBadge<br/>[estado: count]"]
PL --> PC1["ProductCard<br/>[estado: expanded]"]
PL --> PC2["ProductCard<br/>[estado: expanded]"]
PL --> PC3["ProductCard<br/>[estado: expanded]"]
Fíjate en un detalle que resume "estado local": cada ProductCard tiene su propio expanded. Expandir la primera tarjeta no expande las otras dos, porque cada instancia del componente tiene su propia celda de estado. Tres ProductCard en pantalla son tres libretas independientes. (Cuando un estado necesita ser compartido entre componentes —como el carrito, que CartBadge muestra pero ProductCard alimenta—, ese estado tiene que subir al ancestro común App y bajar por props: eso es levantar el estado, el módulo 7. Aquí cada pieza se queda donde nace.)
Checklist del proyecto
Antes de dar por terminado el mini-proyecto, verifica que aplicaste bien cada concepto:
- Estado vs props (L2): ¿cada dato está donde corresponde? El producto llega por props; el
query, elexpandedy elcountson estado. No copiaste una prop en estado. useState(L3): ¿declaraste cada estado en el tope del componente, conconst [x, setX] = useState(inicial)? ¿SinuseStatedentro deifo después de unreturn?- Snapshot (L4): ¿evitaste leer el valor "nuevo" justo después del setter, sabiendo que la variable es la foto del render?
- Re-render (L5): ¿la UI se deriva del estado (
expanded ? ... : ...) en vez de manipular el DOM a mano? - Updater (L6): ¿usaste
setX(x => ...)donde el nuevo valor depende del anterior (e => !e,c => c + 1) y el valor directo donde no (setQuery(texto))? - Inmutabilidad (L7): ¿construyes objetos/arrays nuevos (
[...],{...}) en vez de mutar? ¿Guardas solo lo que cambia por sí mismo, derivando el resto?
Si las seis casillas están marcadas, integraste el módulo.
Errores comunes
Mezclar estado local que debería estar compartido. Qué pasa: se pone el carrito como estado local de un componente (por ejemplo, dentro de CartBadge) cuando otros componentes (ProductCard) necesitan modificarlo. Por qué pasa: en este módulo aprendimos estado local, y es tentador meter todo estado donde se usa primero. Cómo detectarlo: dos componentes necesitan el mismo dato y no logras conectarlos sin duplicar. Cómo corregirlo: si un estado lo necesitan varios componentes, no va local en uno; sube al ancestro común y baja por props. En este proyecto lo mantenemos simple (contador local que solo se muestra), pero la señal de "necesito compartir" es la puerta al módulo 7. No fuerces estado local a hacer de estado compartido.
Querer filtrar la lista guardando la lista filtrada en estado. Qué pasa: al tener el query, se crea otro estado filteredProducts y se intenta mantenerlo sincronizado. Por qué pasa: parece el siguiente paso natural. Cómo detectarlo: tienes dos estados donde uno es siempre función del otro, y bugs de desincronización. Cómo corregirlo: la lista filtrada se deriva de products y query en el render (products.filter(...)), no se guarda. Guarda solo el query (que cambia por sí mismo); calcula el filtrado. En este módulo solo guardamos el query; el filtrado es el módulo 5, y ahí se hace derivando, nunca guardando.
Llamar a los setters "a mano" y creer que eso es el evento. Qué pasa: se ve el modelo ejecutado (ui.setQuery('mo')) y se piensa que así se conectan las interacciones reales. Por qué pasa: en Node llamamos los setters directamente para ilustrar la mecánica. Cómo detectarlo: no sabes cómo hacer que teclear de verdad llame a setQuery. Cómo corregirlo: en React real, el setter se llama desde un manejador de evento: onChange={e => setQuery(e.target.value)} para el input, onClick={() => setExpanded(e => !e)} para el toggle. Ese cableado —qué evento, cómo se obtiene el dato del evento, cómo se pasa al setter— es el módulo 4. Aquí aprendiste a declarar y actualizar el estado; conectarlo a las acciones del usuario es el paso inmediatamente siguiente.
Ejercicios
Ejercicio 1 — Agrega el toggle "solo disponibles". Diseña (código React) una pieza de estado para la SearchBar que sea un booleano onlyInStock (mostrar solo productos con stock). Declara el estado, describe cómo se vería el JSX del checkbox, y escribe el setter que lo alterna. No conectes el evento (módulo 4); solo la declaración y el setter.
Ver solución
function SearchBar(props) {
const [query, setQuery] = useState('');
const [onlyInStock, setOnlyInStock] = useState(false); // nuevo estado booleano
return (
<div className="search-bar">
<input value={query} placeholder={props.placeholder} />
<label>
<input type="checkbox" checked={onlyInStock} />
Only in stock
</label>
{/* al cambiar el checkbox: setOnlyInStock(v => !v) — evento en modulo 4 */}
</div>
);
}
El setter para alternar: setOnlyInStock(v => !v). Usamos el updater funcional porque el nuevo valor depende del anterior (es lo contrario de lo que había). Alternativamente, si tienes el valor del evento, setOnlyInStock(e.target.checked) con el valor directo también sirve —pero para un toggle puro, v => !v es lo idiomático—.
Notas: onlyInStock es estado (cambia por acción del usuario, es propio de la barra); arranca en false; se declara en el tope junto a query. La lista efectivamente filtrada por este booleano se derivaría en el render (módulo 5), no se guardaría en otro estado.
Ejercicio 2 — Encuentra los tres errores. Este componente tiene tres problemas de los que vimos en el módulo. Encuéntralos y corrígelos:
function ProductCard(props) {
if (!props.inStock) return <p>Out of stock</p>;
const [expanded, setExpanded] = useState(false);
const [tags, setTags] = useState(props.tags);
function addTag(tag) {
tags.push(tag);
setTags(tags);
}
return <article onClick={() => expanded = true}>{props.name}</article>;
}
Ver solución
Error 1 — hook después de un return condicional. El useState está después de if (!props.inStock) return ..., así que a veces no se llama. Viola las reglas de los hooks (lección 3). Corrección: los useState van en el tope, antes de cualquier return.
Error 2 — mutar el estado. tags.push(tag); setTags(tags) muta el array y le pasa la misma referencia; React no re-renderiza (lección 7). Corrección: construir un array nuevo, con updater: setTags(prev => [...prev, tag]).
Error 3 — cambiar el estado con una asignación. onClick={() => expanded = true} intenta reasignar la variable de estado; no toca la libreta de React ni dispara re-render (lecciones 3 y 5), y además expanded es una const. Corrección: usar el setter: onClick={() => setExpanded(e => !e)}.
Corregido:
function ProductCard(props) {
const [expanded, setExpanded] = useState(false); // hooks en el tope
const [tags, setTags] = useState(props.tags);
function addTag(tag) {
setTags(prev => [...prev, tag]); // inmutable + updater
}
if (!props.inStock) return <p>Out of stock</p>; // el return condicional, despues
return (
<article onClick={() => setExpanded(e => !e)}> {/* setter, no asignacion */}
{props.name}
</article>
);
}
(Nota: copiar props.tags en estado con useState(props.tags) solo es correcto si tags es genuinamente un estado editable que arranca desde las props y luego diverge; si solo quieres mostrar las tags del producto, no las pongas en estado, úsalas directo de props —lección 2—.)
Ejercicio 3 — Traza la secuencia. Sin correr nada, predice la salida (las líneas render #N: ...) del ejemplo trabajado si la secuencia de interacciones fuera:
render(); // montaje
ui.setSubmits(n => n + 1);
ui.setQuery('hub');
ui.setSubmits(n => n + 1);
Ver solución
render #1: query="" submits=0
render #2: query="" submits=1
render #3: query="hub" submits=1
render #4: query="hub" submits=2
Paso a paso, recordando que cada pieza de estado vive en su propia celda y solo cambia la que tocas:
- #1 (montaje): ambas en su inicial →
query="",submits=0. - #2:
setSubmits(n => n + 1)subesubmitsde 0 a 1 (el updater sobre el valor actual).queryno se tocó, sigue"". →query="" submits=1. - #3:
setQuery('hub')ponequeryen"hub"(valor directo).submitsno se tocó, sigue1. →query="hub" submits=1. - #4:
setSubmits(n => n + 1)subesubmitsde 1 a 2.querysigue"hub". →query="hub" submits=2.
La lección: cada setter re-renderiza el componente, pero solo modifica su celda; las demás conservan su valor. Cuatro renders para el montaje más tres cambios.
Resumen y siguiente paso
En este mini-proyecto integraste las seis piezas del módulo dándole vida al storefront estático del módulo 1. Escribiste el código React real de los componentes con estado —la SearchBar con query, el ProductCard con expanded, el contador con count— aplicando a la vez la distinción estado/props, useState, el snapshot, el re-render, el updater y la inmutabilidad. Y lo ejecutaste en Node: viste dos piezas de estado convivir e independizarse a lo largo de cinco renders (cambiar una no toca la otra), mediste la trampa del snapshot (2x sin updater → 4) y su fix (2x con updater → 5), y comprobaste que crear un array nuevo (referencia distinta) es lo que hace visible el cambio. Repasaste el árbol con estado local (cada ProductCard con su propio expanded) y el checklist de los seis conceptos.
Con esto dominas el estado local con useState: sabes declararlo, distinguirlo de las props, actualizarlo entendiendo el snapshot y el re-render, encadenar cambios con el updater, y respetar la inmutabilidad. Es el corazón de la interactividad en React.
El siguiente módulo, el 4 (eventos y handlers), conecta este estado con las acciones del usuario: el onChange que llama a setQuery cuando tecleas, el onClick que llama a setExpanded cuando das clic, y cómo pasar datos hacia arriba por callbacks (onAddToCart). Ahí los setters que llamaste "a mano" en este módulo pasarán a dispararse solos, en respuesta a lo que hace el usuario, y el storefront dejará de necesitar que lo empujes: cobrará vida de verdad.
Recursos
- React, "State: A Component's Memory" — react.dev/learn/state-a-components-memory. El repaso integral del estado con
useState; útil para consolidar todo el módulo antes del proyecto. En inglés. - React, "Choosing the State Structure" — react.dev/learn/choosing-the-state-structure. Cómo decidir cuántas piezas de estado y dónde ponerlas; la guía para el diseño de estado del proyecto. En inglés.
- React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. Adelanto de levantar el estado (módulo 7): qué hacer cuando un estado local necesita compartirse. En inglés.
- React, "Responding to Events" — react.dev/learn/responding-to-events. Adelanto del módulo 4: cómo los eventos (
onClick,onChange) llaman a los setters que aquí invocamos a mano. En inglés.