Módulo 3: State With Usestate

No mutes el estado

Descripción

Llegamos a la última regla del estado, y la que atrapa a casi todo el que empieza con objetos y arrays: no mutes el estado, crea uno nuevo. Cuando el estado es un número, un string o un booleano, esta regla no se nota —setCount(5) ya te da un valor nuevo—. Pero cuando el estado es un objeto o un array (el carrito, un producto con varios campos, una lista de favoritos), aparece la tentación de modificarlo "en el sitio": cart.push(item), product.inStock = false. Y eso, en React, no funciona: cambias el dato, pero la pantalla no reacciona. El bug es silencioso y desconcertante, hasta que entiendes la razón.

La razón es concreta y la vas a medir. React decide si un componente necesita re-renderizarse comparando el estado nuevo con el viejo, y lo hace por identidad de referencia (con Object.is), no campo por campo. Es decir, React no pregunta "¿el contenido del array es distinto?"; pregunta "¿es otro array (otra referencia en memoria)?". Cuando mutas —cart.push(item)—, el array cambia por dentro pero sigue siendo la misma referencia: la comparación da "igual", y React concluye "nada cambió, no re-renderizo". Tu dato está modificado, pero la pantalla no se entera. Cuando en cambio creas un array nuevo[...cart, item]—, es otra referencia, la comparación da "distinto", y React re-renderiza.

De ahí la regla: para cambiar un objeto o array del estado, no lo modifiques; construye uno nuevo con los cambios aplicados. {...product, inStock: false} en vez de product.inStock = false. [...cart, item] en vez de cart.push(item). Tratas el estado como inmutable: nunca lo tocas por dentro; siempre lo reemplazas por una versión nueva.

Conexión con el módulo. Esta lección completa las tres propiedades del estado que anuncié en la presentación: es un snapshot (4), cambiarlo dispara re-render (5), y —esta— no se muta. Se apoya en todo lo anterior: el re-render (5) es lo que la mutación impide que ocurra; el updater funcional (6) es la forma idiomática de construir el nuevo valor a partir del anterior sin mutar (setCart(prev => [...prev, item])). Y prepara el módulo 5 y el 7: la inmutabilidad es lo que hace que el flujo de datos siga siendo trazable cuando el estado son estructuras, y "una pieza de estado por cosa que cambia" —que consolidamos aquí— es la regla que evita el estado derivado que el módulo 5 desarrolla.

Una analogía: la fotocopia con correcciones, no el tachón sobre el original

Imagina que en una oficina hay un documento oficial —digamos, la lista de pedidos del día— y el sistema de la empresa detecta que hubo cambios comparando la hoja actual con la anterior: si le entregas otra hoja, sabe que hubo novedad y actualiza los tableros; si le entregas la misma hoja de siempre, asume que nada cambió y no hace nada.

Ahora tienes que agregar un pedido. Hay dos maneras.

La mutación — el tachón sobre el original. Tomas la hoja oficial y, con un bolígrafo, escribes el pedido nuevo al final de la misma hoja. El contenido cambió, sí. Pero cuando el sistema compara, ve la misma hoja que ya tenía registrada (mismo papel, mismo folio) y concluye: "es la de siempre, nada nuevo". No actualiza los tableros. Tu pedido está escrito, pero nadie lo ve reflejado. Eso es cart.push(item): modificas el array, pero es la misma referencia, y React no re-renderiza.

La inmutabilidad — la fotocopia con el cambio. En vez de tachar el original, sacas una fotocopia de la lista, le agregas el pedido nuevo a la copia, y entregas esa hoja nueva. El sistema compara, ve que es otra hoja (otro papel), y concluye: "hay novedad", y actualiza los tableros. Tu pedido se refleja. Eso es [...cart, item]: construyes un array nuevo con el cambio, es otra referencia, y React re-renderiza.

La moraleja: como el sistema detecta cambios por "¿es otra hoja?" y no por "¿dice algo distinto?", tienes que entregarle siempre una hoja nueva cuando quieras que note el cambio. Nunca taches el original: aunque lo modifiques, para el sistema sigue siendo el mismo. Guarda la fotocopia con correcciones: es la inmutabilidad, y es exactamente cómo React compara el estado.

Ejemplo trabajado: React "no ve" la mutación

Vamos a medir por qué mutar no dispara el re-render y crear nuevo sí. Modelamos la decisión de React: comparar el estado nuevo con el viejo por referencia (Object.is) y re-renderizar solo si son distintos. Lo probamos con un array (el carrito) y con un objeto (un producto).

Así se ve en React de verdad, el contraste que importa:

import { useState } from 'react';

function Cart() {
  const [items, setItems] = useState([{ id: 'p1', name: 'Wireless Mouse', qty: 1 }]);

  function addWrong(item) {
    items.push(item);   // MUTA el array: misma referencia -> React no re-renderiza
    setItems(items);    // le pasa la MISMA referencia
  }

  function addRight(item) {
    setItems([...items, item]); // array NUEVO: otra referencia -> React re-renderiza
  }

  return <span>{items.length} items</span>;
}

Y la versión ejecutable en Node. setStateWithBailout modela la decisión de React: si el nuevo estado es la misma referencia que el viejo (Object.is), no re-renderiza (hace bailout); si es distinta, sí:

'use strict';

// ── React decide si re-renderiza comparando el estado nuevo con el viejo
//    por IDENTIDAD (Object.is). Misma referencia => "no cambio" => no re-render.
function setStateWithBailout(prev, next) {
  if (Object.is(prev, next)) {
    return { rerender: false, state: prev }; // React NO re-renderiza
  }
  return { rerender: true, state: next };
}

// ── Arrays: el carrito ──
console.log('=== ARRAY: mutar vs crear uno nuevo ===');
const cart = [{ id: 'p1', name: 'Wireless Mouse', qty: 1 }];

// MAL: mutar el array existente con push -> MISMA referencia
const mutated = cart;
mutated.push({ id: 'p2', name: 'USB-C Hub', qty: 1 });
const rA = setStateWithBailout(cart, mutated);
console.log('A) cart.push(item):     misma referencia?', Object.is(cart, mutated), '| React re-renderiza?', rA.rerender);

// BIEN: crear un array NUEVO con spread -> referencia DISTINTA
const cart2 = [{ id: 'p1', name: 'Wireless Mouse', qty: 1 }];
const nextCart = [...cart2, { id: 'p2', name: 'USB-C Hub', qty: 1 }];
const rB = setStateWithBailout(cart2, nextCart);
console.log('B) [...cart, item]:     misma referencia?', Object.is(cart2, nextCart), '| React re-renderiza?', rB.rerender);

// ── Objetos: alternar inStock de un producto ──
console.log('\n=== OBJETO: mutar vs crear uno nuevo ===');
const product = { id: 'p1', name: 'Wireless Mouse', inStock: true };

// MAL: mutar el campo -> MISMA referencia
const same = product;
same.inStock = false;
const rC = setStateWithBailout(product, same);
console.log('C) product.inStock=false:      misma referencia?', Object.is(product, same), '| React re-renderiza?', rC.rerender);

// BIEN: nuevo objeto con spread -> referencia DISTINTA
const product2 = { id: 'p1', name: 'Wireless Mouse', inStock: true };
const nextProduct = { ...product2, inStock: false };
const rD = setStateWithBailout(product2, nextProduct);
console.log('D) {...product, inStock:false}: misma referencia?', Object.is(product2, nextProduct), '| React re-renderiza?', rD.rerender);

Qué esperar. Al correr el archivo, la salida es exactamente esta:

=== ARRAY: mutar vs crear uno nuevo ===
A) cart.push(item):     misma referencia? true | React re-renderiza? false
B) [...cart, item]:     misma referencia? false | React re-renderiza? true

=== OBJETO: mutar vs crear uno nuevo ===
C) product.inStock=false:      misma referencia? true | React re-renderiza? false
D) {...product, inStock:false}: misma referencia? false | React re-renderiza? true

Esta salida es la regla entera, medida. Léela en pares (A/B para arrays, C/D para objetos).

A) La mutación es invisible. cart.push(item) agregó el producto al array —el contenido cambió—, pero Object.is(cart, mutated) da true: es la misma referencia (de hecho, mutated es cart; solo le pusimos otro nombre). Como React compara por referencia, ve "el mismo array de siempre" y decide no re-renderizar (false). Este es el bug silencioso: tu carrito tiene dos productos, pero la pantalla sigue mostrando uno, porque React nunca se enteró de que algo cambió. No hay error, no hay pista; simplemente la UI se queda atrás.

B) El array nuevo sí se ve. [...cart2, item] construyó un array nuevo con el contenido viejo más el producto. Object.is(cart2, nextCart) da false: son referencias distintas. React ve "otro array", concluye que hubo un cambio, y re-renderiza (true). La pantalla se actualiza. La diferencia con A no es el contenido (los dos terminan con dos productos); es que B entregó otra hoja y A tachó la misma.

C y D) Lo mismo para objetos. product.inStock = false muta el campo pero deja la misma referencia (true) → no re-render. {...product2, inStock: false} crea un objeto nuevo con inStock cambiado → referencia distinta (false) → re-render. La mecánica es idéntica: mutar un campo es invisible; construir un objeto nuevo con el campo cambiado es visible.

La conclusión medida: la mutación cambia el dato pero no la referencia, y React solo mira la referencia. Por eso "React no ve la mutación". La solución universal es construir estructuras nuevas.

El recetario de la inmutabilidad

No hace falta memorizar teoría; hace falta conocer los patrones. Estos cubren casi todo:

Operacion               MUTA (mal)                Version INMUTABLE (bien)
──────────────────────  ────────────────────────  ──────────────────────────────────
Agregar a un array      arr.push(x)                [...arr, x]
Quitar de un array      arr.splice(i, 1)           arr.filter(el => el.id !== id)
Cambiar un campo        obj.campo = v              { ...obj, campo: v }
Cambiar un item de      arr[i].campo = v           arr.map(el => el.id === id
  una lista                                          ? { ...el, campo: v } : el)
Vaciar                  arr.length = 0             []

Fíjate en el patrón común: el lado "bien" nunca toca la estructura original; siempre produce una nueva con [...], {...}, .map o .filter (todos crean copias). Con el updater funcional de la lección 6, se combinan naturalmente:

setCart(prev => [...prev, item]);                 // agregar
setCart(prev => prev.filter(i => i.id !== id));   // quitar
setProduct(prev => ({ ...prev, inStock: false })); // cambiar campo

El prev te da el valor pendiente sin mutarlo, y devuelves una estructura nueva. Es la forma idiomática de actualizar objetos y arrays en estado.

La otra mitad: una pieza de estado por cosa que cambia

Hay una decisión previa a "cómo actualizo el estado", y es "cuánto estado tengo". La regla es: una pieza de estado por cosa que cambia de forma independiente, y nada más. Todo lo que se pueda calcular a partir de ese estado no se guarda; se deriva en el render.

Ejemplo del storefront. Tienes el catálogo (productos) y el query de búsqueda. La lista filtrada —los productos que coinciden con el query— no es una pieza de estado: se calcula de los dos (products.filter(p => p.name.includes(query))) en cada render. Si la guardaras en su propio estado, tendrías que acordarte de recalcularla cada vez que cambia el query o el catálogo, y tarde o temprano se te olvida y muestra resultados desactualizados —el clásico bug del estado derivado—. Igual con el total del carrito: no es estado; es items.reduce((sum, i) => sum + i.priceCents * i.qty, 0), calculado del carrito.

La prueba para decidir: ¿este dato puede calcularse a partir de otro estado o de props? Si sí, no es estado: derívalo. Si no —si cambia por sí mismo y no se puede reconstruir de otra cosa—, es estado. Menos estado significa menos cosas que mantener sincronizadas, y menos bugs. (El módulo 5 desarrolla esto a fondo, con el filtrado y el orden del ProductList ejecutados; aquí basta con instalar la regla.)

Errores comunes

Mutar un array o un objeto del estado. Qué pasa: se hace cart.push(item), items[0].qty = 2, product.inStock = false, o arr.sort() sobre el estado, y la UI no se actualiza. Por qué pasa: son las operaciones "naturales" de JavaScript para cambiar arrays y objetos, y funcionan... para el dato, pero no para React. Cómo detectarlo: cambias el estado y la pantalla no reacciona, sin ningún error. Cómo corregirlo: nunca mutes el estado; construye una estructura nueva. [...cart, item] en vez de push; arr.filter(...) en vez de splice; {...obj, campo: v} en vez de asignar el campo; [...arr].sort() (copia primero) en vez de arr.sort(). React compara por referencia; solo una referencia nueva dispara el re-render.

Copiar "superficial" cuando el cambio es profundo. Qué pasa: se hace {...state} pero luego se muta un objeto anidado dentro: const next = {...cart}; next.items.push(item). Por qué pasa: el spread copia el nivel de arriba, pero los objetos y arrays anidados siguen siendo las mismas referencias; mutarlos muta también el original. Cómo detectarlo: creaste "una copia" pero el estado original también cambió, o React no re-renderiza el sub-nivel. Cómo corregirlo: crea estructuras nuevas en cada nivel que cambia. Para agregar a cart.items: { ...cart, items: [...cart.items, item] } —objeto nuevo y array nuevo—. El spread es superficial; si el cambio es profundo, copia hasta la profundidad que tocas.

Guardar en estado lo que se puede derivar. Qué pasa: se crea un estado filteredProducts además de products y query, y hay que mantenerlo sincronizado a mano. Por qué pasa: parece eficiente "tenerlo listo". Cómo detectarlo: tienes un estado que siempre es función de otros, y bugs donde ese estado se queda desactualizado (muestra resultados que ya no coinciden con el query). Cómo corregirlo: no lo guardes; calcúlalo en el render a partir del estado que sí es fuente (const filtered = products.filter(p => matches(p, query))). Una pieza de estado por cosa que cambia; el resto se deriva. Menos estado, menos sincronización, menos bugs. (El módulo 5 lo trata a fondo.)

Ejercicios

Ejercicio 1 — Muta o no muta. Para cada operación sobre un estado items (un array) o product (un objeto), di si muta (mal, React no re-renderiza) o crea nuevo (bien), y por qué:

// (a)
items.push(newItem);
// (b)
const next = [...items, newItem];
// (c)
product.qty = product.qty + 1;
// (d)
const next = { ...product, qty: product.qty + 1 };
// (e)
const next = items.filter(i => i.id !== removedId);
Ver solución
  • (a) items.push(newItem): MUTA. push modifica el array existente; sigue siendo la misma referencia. React no re-renderiza.
  • (b) [...items, newItem]: CREA NUEVO. El spread construye un array nuevo con el contenido viejo más el item. Otra referencia; React re-renderiza.
  • (c) product.qty = ...: MUTA. Asignar a un campo modifica el objeto existente; misma referencia. React no re-renderiza.
  • (d) { ...product, qty: ... }: CREA NUEVO. El spread crea un objeto nuevo con qty cambiado. Otra referencia; React re-renderiza.
  • (e) items.filter(...): CREA NUEVO. filter devuelve un array nuevo con los elementos que pasan la prueba (aquí, todos menos el removido). Otra referencia; React re-renderiza. (A diferencia de splice, que mutaría.)

El patrón: push, splice, sort, asignar un campo → mutan. [...], {...}, map, filter → crean nuevo. React solo ve lo segundo.

Ejercicio 2 — Arregla el addToCart. Este handler agrega un producto al carrito pero la pantalla no se actualiza. Explica por qué y reescríbelo (usa el updater funcional):

function addToCart(product) {
  cart.push({ ...product, qty: 1 });
  setCart(cart);
}
Ver solución

Por qué no se actualiza: cart.push(...) muta el array cart existente. Luego setCart(cart) le pasa a React la misma referencia que ya tenía en el estado. React compara con Object.is, ve que es el mismo array, y decide no re-renderizar —aunque por dentro el array ahora tenga un producto más—. El carrito cambió, pero la pantalla no lo refleja: el bug silencioso.

Reescrito, con un array nuevo y updater funcional:

function addToCart(product) {
  setCart(prev => [...prev, { ...product, qty: 1 }]);
}

Ahora [...prev, ...] construye un array nuevo con el contenido anterior más el producto: otra referencia, así que React re-renderiza y la pantalla se actualiza. El updater prev => ... toma el valor pendiente sin mutarlo (robusto si hay varios addToCart seguidos, lección 6) y devuelve la estructura nueva. Nota que también creamos un objeto nuevo para el item ({ ...product, qty: 1 }) en vez de mutar product.

Ejercicio 3 — ¿Estado o derivado? Para un carrito, decide cuáles de estos deben ser piezas de estado y cuáles se derivan, y justifica: (a) la lista de items del carrito; (b) el número total de items (la suma de cantidades); (c) el total en centavos; (d) si el carrito está vacío.

Ver solución
  • (a) La lista de items: ESTADO. Es la fuente que cambia por sí misma (el usuario agrega/quita productos) y no se puede reconstruir de otra cosa. Es la pieza de estado del carrito: const [items, setItems] = useState([]).
  • (b) El total de items: DERIVADO. Se calcula de la lista: items.reduce((sum, i) => sum + i.qty, 0). No es estado; se computa en el render. Guardarlo aparte obligaría a sincronizarlo cada vez que cambia la lista.
  • (c) El total en centavos: DERIVADO. Se calcula de la lista: items.reduce((sum, i) => sum + i.priceCents * i.qty, 0). Igual que (b): función de la fuente, se deriva.
  • (d) Si está vacío: DERIVADO. Es items.length === 0, un cálculo trivial de la lista. No es estado.

El patrón: solo la lista de items es estado (la fuente que cambia sola); todo lo demás —cuántos hay, cuánto cuestan, si está vacío— se deriva de ella en cada render. Una pieza de estado por cosa que cambia; el resto se calcula. Así no hay nada que mantener sincronizado a mano, y no puede desincronizarse. (El módulo 5 desarrolla el porqué a fondo.)

Resumen y siguiente paso

En esta lección cerraste el modelo del estado con su última regla: no mutes el estado, crea uno nuevo. Mediste la razón: React decide si re-renderiza comparando el estado nuevo con el viejo por referencia (Object.is), no por contenido. Una mutación (cart.push(item), product.inStock = false) cambia el dato pero deja la misma referencia, así que React "no la ve" y no re-renderiza (el bug silencioso). Un valor nuevo ([...cart, item], {...product, inStock: false}) tiene referencia distinta y dispara el render. Te llevas el recetario de la inmutabilidad ([...], {...}, map, filter crean; push, splice, asignar mutan), la advertencia sobre copias superficiales en estructuras anidadas, y la regla "una pieza de estado por cosa que cambia" —lo derivable se calcula, no se guarda—.

Antes de avanzar deberías poder: explicar por qué React "no ve" una mutación (compara referencias); reescribir una mutación como creación de estructura nueva (con el recetario); combinar inmutabilidad con el updater funcional; y decidir si un dato es estado o derivado.

Con esto tienes el modelo completo del estado: estado vs props (2), useState (3), snapshot (4), re-render (5), updater (6) e inmutabilidad (7). La lección 8 lo pone todo junto en el mini-proyecto: le das vida al storefront estático del módulo 1, agregando estado real —el query de la SearchBar, un toggle en ProductCard, un contador— y aplicando a la vez el snapshot, el re-render, el updater y la inmutabilidad. Entregarás el código React con useState y la lógica ejecutada en Node. Es donde compruebas que las seis piezas encajan.

Recursos