Módulo 7: Lifting State And Composition

`useReducer` en React: `dispatch` y cuándo preferirlo

Descripción

En la lección 4 escribiste el cartReducer como una función pura y lo probaste en Node, aislado de React. Pero un reducer suelto no maneja el estado de un componente: le falta la celda de memoria que persiste entre renders y el disparo del re-render (lo que useState te dio en el módulo 3). Esta lección instala el puente: useReducer, el hook que mete un reducer dentro de un componente. Con una línea —const [cart, dispatch] = useReducer(cartReducer, [])— el componente obtiene el estado (cart) y una función para cambiarlo, dispatch. Y aquí está el cambio de mentalidad: el componente ya no lleva la lógica de cómo cambia el carrito; solo despacha acciones (dispatch({ type: 'add', product })), y el reducer —esa función pura de la lección 4— decide el estado nuevo. Lo vas a ver ejecutado con un mini-runtime de useReducer, y vas a aprender la decisión práctica que cierra el tema: cuándo useReducer gana a useState y cuándo useState sigue siendo lo correcto.

Conexión con el módulo. Es la lección que vuelve usable el reducer de la 4. La 4 te dio la función pura (verificable, aislada); esta la enchufa a React. Junto con las lecciones 2 y 3, completa el manejo del carrito: dónde vive (levantado en el App, lecciones 2-3) y cómo cambia (useReducer + cartReducer, lecciones 4-5). Con esto listo, el módulo pasa a componer (lecciones 6-7): cómo baja este estado a los hijos sin encadenar props.

Una analogía: el ticket a la cocina

Vuelve al restaurante del módulo 4, pero mira ahora la comanda. Cuando pides, tú no entras a la cocina, no agarras las ollas, no decides cómo se saltea la pasta. Haces algo mucho más simple: escribes (o dictas) un ticket —"una pasta, sin ajo"— y lo dejas en la ventanilla. La cocina, siguiendo sus recetas, lo recibe y produce el plato. Tú describes lo que quieres que pase; la cocina decide cómo hacerlo. No necesitas saber cocinar para pedir bien; solo necesitas escribir un ticket claro.

dispatch es dejar el ticket en la ventanilla. La acción ({ type: 'add', product }) es el ticket: describe qué pasó / qué se pide, no cómo cambiar el estado. La cocina es el cartReducer: recibe el ticket (la acción) y el estado actual, y produce el estado nuevo siguiendo sus recetas (sus case). Y useReducer es lo que conecta la ventanilla con la cocina y con la mesa: te devuelve el plato actual (cart, el estado) y la ventanilla (dispatch) para dejar tickets.

Fíjate en lo que se gana con este arreglo. El componente que despacha —el mesero que anota— no necesita saber cocinar: no lleva la lógica de subir cantidades ni de quitar líneas. Solo escribe tickets claros y los deja. Toda la receta vive en un solo lugar (la cocina, el reducer), aislada y probada. Comparado con tener al mesero improvisando el plato en la mesa (lógica de estado desperdigada en los handlers), es más ordenado, más fácil de cambiar (cambias la receta en un lugar) y más fácil de probar (pruebas la cocina sola, como hiciste en la lección 4).

Ejemplo trabajado: useReducer conectado, con un ciclo de dispatch

Primero, cómo se ve en React de verdad. useReducer recibe el reducer y el estado inicial, y devuelve [state, dispatch]. El componente muestra el estado y, en los eventos, despacha acciones:

function App() {
  // useReducer conecta el cartReducer: devuelve el estado y la ventanilla (dispatch).
  const [cart, dispatch] = useReducer(cartReducer, []);

  return (
    <div className="app">
      <ProductList
        products={PRODUCTS}
        // el handler solo DESPACHA un ticket; la cocina (reducer) decide
        onAddToCart={(product) => dispatch({ type: 'add', product })}
      />
      <Cart
        items={cart}
        onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
        onClear={() => dispatch({ type: 'clear' })}
      />
    </div>
  );
}

Compara esto con lo que sería el mismo App usando useState con la lógica en los handlers: cada handler tendría que buscar la línea, decidir si sube cantidad o agrega, copiar el array sin mutar… todo repartido y repetido. Con useReducer, los handlers son de una línea —despachar un ticket—, y la lógica vive entera y limpia en el cartReducer. Esa es la ganancia.

Ahora ejecutémoslo. Como en el módulo 3 modelamos useState con una celda de memoria que persiste entre renders, aquí modelamos useReducer igual, pero cambiando el estado a través del reducer: dispatch(action) corre cartReducer(cell, action) y dispara un re-render.

function cartReducer(state, action) { /* ...igual que la leccion 4: add / remove / clear... */ }
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }

// --- mini-runtime de useReducer (como el useState del modulo 3, con reducer) ---
let cell;
let firstRender = true;
function useReducer(reducer, initial) {
  if (firstRender) cell = initial;
  function dispatch(action) {
    cell = reducer(cell, action); // corre el reducer PURO
    render();                     // cambiar el estado dispara un re-render
  }
  return [cell, dispatch];
}

let dispatch;
function render() {
  const [cart, d] = useReducer(cartReducer, []);
  dispatch = d;
  firstRender = false;
  const items = cart.map((i) => `${i.name} x${i.qty}`).join(', ');
  console.log(`  render -> Cart: [${items}] total ${formatPrice(cartTotal(cart))}`);
}

const MOUSE    = { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 };
const KEYBOARD = { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 };

console.log('=== useReducer en accion: dispatch(action) -> reducer -> re-render ===\n');
render(); // primer render
console.log("dispatch({ type: 'add', product: MOUSE }) ->");
dispatch({ type: 'add', product: MOUSE });
console.log("dispatch({ type: 'add', product: KEYBOARD }) ->");
dispatch({ type: 'add', product: KEYBOARD });
console.log("dispatch({ type: 'add', product: MOUSE }) ->");
dispatch({ type: 'add', product: MOUSE });
console.log("dispatch({ type: 'clear' }) ->");
dispatch({ type: 'clear' });

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

=== useReducer en accion: dispatch(action) -> reducer -> re-render ===

  render -> Cart: [] total $0.00
dispatch({ type: 'add', product: MOUSE }) ->
  render -> Cart: [Wireless Mouse x1] total $25.99
dispatch({ type: 'add', product: KEYBOARD }) ->
  render -> Cart: [Wireless Mouse x1, Mechanical Keyboard x1] total $114.99
dispatch({ type: 'add', product: MOUSE }) ->
  render -> Cart: [Wireless Mouse x2, Mechanical Keyboard x1] total $140.98
dispatch({ type: 'clear' }) ->
  render -> Cart: [] total $0.00

Lee la salida como el ciclo que es, y notarás que es idéntico al de useState del módulo 3 pero con dispatch en lugar de set. El primer render muestra el carrito vacío (el estado inicial []). Luego cada dispatch deja un ticket: dispatch({ type: 'add', product: MOUSE }) corre cartReducer(cell, action) —la cocina—, que devuelve el carrito nuevo, y eso dispara un re-render; por eso, justo debajo, aparece Cart: [Wireless Mouse x1] total $25.99. El segundo add mete el keyboard; el tercer add (otra vez el mouse) sube la cantidad a x2 (la receta del reducer, la misma que probaste en la lección 4); y clear deja el carrito vacío.

Detente en el reparto: en ningún dispatch escribimos la lógica de cómo cambia el carrito. Solo dejamos tickets ({ type, ... }). La lógica —subir cantidad, no duplicar la línea, vaciar— vive entera en cartReducer, la función pura de la lección 4, que aquí simplemente conectamos. El componente despacha; el reducer decide; React re-renderiza. Ese es el ciclo de useReducer, y es el de useState con la lógica sacada a un lugar limpio.

Profundización: dispatch, y useReducer vs useState

useReducer devuelve [state, dispatch], no [state, setState]. La diferencia con useState es qué te dan para cambiar el estado. useState te da un setter al que le pasas el valor nuevo (o un updater): setCart(nuevoCart). useReducer te da un dispatch al que le pasas una acción (un ticket): dispatch({ type: 'add', product }), y el reducer calcula el valor nuevo. Con useState, el "cómo cambiar" vive en quien llama al setter. Con useReducer, el "cómo cambiar" vive en el reducer, y quien llama solo describe "qué pasó".

dispatch es estable entre renders. Un detalle práctico: React garantiza que la función dispatch no cambia de un render a otro (a diferencia de un handler nuevo que defines en cada render). Eso la hace segura para pasar hacia abajo por props o como dependencia de un efecto, sin causar renders extra. No necesitas memorizar esto ahora; tenlo presente para cuando bajes dispatch a componentes hijos.

Cuándo useReducer gana a useState. No siempre; useState es lo simple y correcto para la mayoría del estado. Prefiere useReducer cuando:

  • El estado tiene varios sub-valores que cambian juntos con reglas. El carrito no es un número: es una lista de líneas con cantidades, y cada acción la transforma con reglas (subir/bajar cantidad, quitar, vaciar). Consolidar eso en un reducer lo mantiene coherente.
  • La lógica de las transiciones es compleja o se repite. Si tienes cinco handlers que hacen variaciones de "buscar la línea, decidir, copiar sin mutar", un reducer reúne esa lógica una vez.
  • Quieres probar la lógica aislada. El reducer es una función pura: se prueba sin React (lección 4). Si te importa testear la lógica del estado, el reducer te lo regala.
  • El próximo estado depende de forma intrincada del anterior. Cuando "qué hacer" depende mucho de "dónde estabas", un reducer con switch lo expresa más claro que setters sueltos.

Cuándo quedarte con useState. Para estado simple e independiente: un booleano (expanded), un string (query), un número (count), un objeto pequeño sin lógica de transición. Meterle un reducer a un const [expanded, setExpanded] = useState(false) es sobre-ingeniería: setExpanded(!expanded) es más claro que despachar { type: 'toggle' } a un reducer de dos líneas. La regla: useState por defecto; sube a useReducer cuando la lógica del estado se vuelva un enredo de handlers. Es el mismo espíritu de "local por defecto, sube cuando se comparte" de la lección 3, aplicado a la herramienta.

Los dos son intercambiables en principio. Cualquier cosa que hagas con useReducer la puedes hacer con useState y viceversa; no cambian qué puedes lograr, sino qué tan ordenado queda. useReducer no es "más poderoso"; es "más ordenado cuando la lógica lo pide". Elegir bien es parte del oficio.

Errores comunes

Poner la lógica en el handler en vez de en el reducer. Qué pasa: usas useReducer, pero en el onClick calculas el carrito nuevo a mano y despachas { type: 'set', cart: nuevoCart }. El reducer queda como un cascarón y perdiste toda la ventaja. Por qué pasa: la costumbre de calcular el valor nuevo antes de "setearlo", como con useState. Cómo detectarlo: tus acciones cargan el estado ya calculado ({ type: 'set', ... }) en vez de describir qué pasó ({ type: 'add', product }). Cómo corregirlo: la acción describe qué pasó (add con el producto), y el reducer calcula el estado nuevo. El handler solo despacha el ticket; no cocina.

Despachar la acción llamándola en el render. Qué pasa: escribes onClick={dispatch({ type: 'add', product })} con los paréntesis del objeto, o peor, invocas dispatch(...) en el cuerpo del componente, y la acción se despacha en cada render (posible bucle). Por qué pasa: es la trampa del módulo 4 (pasar la función vs llamarla), ahora con dispatch. Cómo detectarlo: el carrito cambia solo al renderizar, sin que nadie haga clic. Cómo corregirlo: envuelve en una flecha para diferir hasta el evento: onClick={() => dispatch({ type: 'add', product })}. Se despacha cuando el usuario actúa, no al pintar.

Usar useReducer para estado trivial (sobre-ingeniería). Qué pasa: le metes un reducer a un simple expanded o query, con acciones { type: 'toggle' } y un switch de dos ramas, y el código se vuelve más largo y ceremonioso sin ganar nada. Por qué pasa: después de aprender reducers, se quieren usar en todo. Cómo detectarlo: tu reducer tiene una o dos ramas y el estado es un solo valor simple. Cómo corregirlo: para eso está useState. Reserva useReducer para estado con varios sub-valores y transiciones con reglas (el carrito). Herramienta según el problema, no al revés.

Ejercicios

Ejercicio 1 — Traduce a acciones. El App maneja el carrito con useReducer(cartReducer, []). Escribe el JSX del handler (la línea de onClick/callback) para cada interacción, despachando la acción correcta: (a) un ProductCard que agrega su product; (b) un CartItem que quita su producto por id; (c) un botón "Clear cart".

Ver solución
// (a) ProductCard: agrega su product
<button onClick={() => dispatch({ type: 'add', product })}>Add to cart</button>

// (b) CartItem: quita por id
<button onClick={() => dispatch({ type: 'remove', id: item.id })}>Remove</button>

// (c) Clear cart
<button onClick={() => dispatch({ type: 'clear' })}>Clear cart</button>

En los tres, el handler solo despacha un ticket (la flecha difiere la llamada hasta el clic, módulo 4). Ninguno calcula el carrito nuevo: eso lo hace el cartReducer. Fíjate en qué datos lleva cada acción: add lleva el product, remove lleva el id, clear no lleva nada. La acción trae lo que su case necesita.

Ejercicio 2 — ¿useReducer o useState? Para cada estado, di cuál usarías y por qué: (a) si un modal está abierto; (b) el carrito con líneas y cantidades (add/remove/clear/setQty); (c) el query de la búsqueda; (d) el estado de un formulario de varios campos con validación y pasos (siguiente/anterior/reset).

Ver solución
  • (a) useState. Un booleano simple, sin lógica de transición. const [open, setOpen] = useState(false). Un reducer sería ceremonia inútil.
  • (b) useReducer. Varios sub-valores (líneas con cantidades) y transiciones con reglas (subir/bajar cantidad, quitar, vaciar). El caso de libro para reducer, como el cartReducer.
  • (c) useState. Un string simple. const [query, setQuery] = useState(''). Nada que consolidar.
  • (d) useReducer. Muchos campos que cambian juntos, validación, y transiciones nombradas (next, prev, reset, setField). Un reducer reúne esa lógica y la vuelve testeable.

La regla: useState para estado simple e independiente; useReducer cuando hay varios sub-valores y transiciones con reglas.

Ejercicio 3 — De useState a useReducer. Un App maneja el carrito con useState y un handler que hace todo a mano:

const [cart, setCart] = useState([]);
function handleAdd(product) {
  const line = cart.find((i) => i.id === product.id);
  if (line) {
    setCart(cart.map((i) => i.id === product.id ? { ...i, qty: i.qty + 1 } : i));
  } else {
    setCart([...cart, { ...product, qty: 1 }]);
  }
}

Explica qué mejora al pasar a useReducer(cartReducer, []), y cómo queda el handler.

Ver solución

Con useReducer, la lógica de "buscar la línea, decidir si sube cantidad o agrega, copiar sin mutar" sale del handler y se va al cartReducer (donde ya la tienes, de la lección 4). El handler se reduce a despachar un ticket:

const [cart, dispatch] = useReducer(cartReducer, []);
// el handler ya no calcula nada: solo describe que paso
function handleAdd(product) {
  dispatch({ type: 'add', product });
}
// o, inline en el JSX:
// onAddToCart={(product) => dispatch({ type: 'add', product })}

Qué mejora:

  1. El handler queda de una línea, sin lógica. Y lo mismo pasará con handleRemove, handleClear: todos despachan, ninguno cocina.
  2. La lógica vive en un solo lugar (el reducer), no repartida y repetida entre handlers.
  3. La lógica es testeable aislada (el reducer es puro, lección 4).
  4. Si mañana agregas setQty o descuentos, tocas solo el reducer, no cada handler.

Es el mismo comportamiento, más ordenado. Justo cuándo useReducer gana a useState: la lógica del estado se estaba volviendo un enredo de handlers.

Resumen y siguiente paso

En esta lección conectaste el cartReducer a React con useReducer: const [cart, dispatch] = useReducer(cartReducer, []) le da al componente el estado (cart) y una ventanilla (dispatch) para despachar acciones. El componente ya no lleva la lógica de cómo cambia el carrito —solo despacha tickets (dispatch({ type: 'add', product }))—, y el reducer, la función pura de la lección 4, decide el estado nuevo. Lo anclaste con el ticket a la cocina: describes qué quieres, la cocina (el reducer) cocina; el mesero no improvisa el plato. Lo mediste con un mini-runtime: dispatch → reducer → re-render, idéntico al ciclo de useState pero con la lógica en un lugar limpio. Y aprendiste la decisión práctica: useState por defecto; useReducer cuando el estado tiene varios sub-valores y transiciones con reglas (el carrito), no para un booleano suelto.

Antes de avanzar deberías poder: escribir const [state, dispatch] = useReducer(reducer, initial) y despachar acciones en los handlers (con la flecha, sin llamar en el render); explicar por qué el handler solo despacha y el reducer decide; y elegir entre useState y useReducer según la forma del estado.

Con el carrito ya levantado (dónde) y manejado por un reducer (cómo), quedan dos preguntas de organización, y son las lecciones 6 y 7. La 6: cuando el estado del App tiene que bajar a un hijo profundo, a veces cruza tres o cuatro componentes que no lo usan —el prop drilling—, y verás cómo la composición (pasar children y componentes como props) deja que el dato salte esos niveles. Ahí aparece, por fin, el teaser de Context —cuando ni componer basta— y la remisión a la guía frontend-state-and-data.

Recursos