Módulo 7: Lifting State And Composition

El `cartReducer`: la lógica del carrito como función pura

Descripción

En la lección 3 decidiste dónde vive el carrito: levantado en el App, porque lo comparten ProductList y Cart. Esta lección ataca el cómo cambia. Porque el carrito ya no es una lista tonta a la que solo se le hace push: agregar un producto que ya está debe subir su cantidad, no duplicar la línea; quitar debe bajar la cantidad y borrar la línea cuando llega a cero; y "vaciar" debe dejarlo limpio. Esa lógica, repartida en handlers sueltos por todo el App, se vuelve un enredo difícil de seguir y de probar. React tiene una herramienta para exactamente esto: el reducer. Un reducer consolida toda la lógica de cómo cambia un estado en una sola función pura, de la forma (state, action) => newState: recibe el estado actual y una acción (qué pasó), y devuelve el estado nuevo. Nada más. En esta lección construyes el cartReducer con las acciones add, remove y clear, y —lo mejor— como es una función pura, la vas a probar sin navegador: la corremos con una secuencia de acciones y verás el estado y el total en centavos, literal, y la prueba de que no muta el estado anterior.

Conexión con el módulo. Es el corazón ejecutable del módulo. La lección 3 dejó el carrito levantado en el App; esta le da forma a su lógica. Aquí el reducer es todavía JavaScript puro, sin React —a propósito, para verlo aislado y verificable—; la lección 5 lo conecta a un componente con useReducer y dispatch. Y toda la inmutabilidad del módulo 3 (no mutar; crear estructuras nuevas) reaparece aquí como una regla dura del reducer: un reducer nunca muta el estado que recibe.

Una analogía: el marcador con reglamento

Imagina el marcador de un partido de básquet y la persona que lo opera. Su trabajo es purísimo y aburridísimo, en el mejor sentido: no improvisa nada. Le llega una jugada —"canasta de dos", "tiro libre", "reiniciar por nuevo partido"— y, mirando el marcador actual y el reglamento, calcula el marcador nuevo. Si el marcador dice 10 y llega "canasta de dos", el nuevo es 12. Si llega "reiniciar", el nuevo es 0. La operadora no recuerda nada entre jugadas (el marcador actual siempre se lo dan), no consulta nada de afuera (ni el clima, ni la hora), y no cambia el marcador viejo a mano: produce el número nuevo. Dale el mismo marcador y la misma jugada mil veces, y las mil veces dará el mismo resultado.

Un reducer es esa operadora. El marcador actual es el state (el carrito ahora). La jugada es la action ({ type: 'add', product }, { type: 'remove', id }, { type: 'clear' }). El reglamento es el cuerpo de la función: para cada tipo de jugada, cómo se calcula el estado nuevo. Y el marcador nuevo es el newState que devuelve. La firma entera es (state, action) => newState: dame dónde estás y qué pasó, y te digo dónde quedas.

Lo que hace tan valiosa a esta operadora es que es predecible y aislada. Como no depende de nada externo y no recuerda nada oculto, puedes probarla sola: le pones un marcador de entrada, le das una jugada, y compruebas el marcador de salida —sin montar el estadio, sin público, sin partido de verdad—. Esa es exactamente la propiedad que hace del reducer el mejor amigo de quien quiere verificar código: es una función pura, y una función pura se prueba con una entrada y una salida, sin navegador.

Ejemplo trabajado: el cartReducer, corrido con una secuencia de acciones

Vamos a construir el cartReducer y a correrlo. El estado del carrito es un array de líneas, cada una { id, name, priceCents, qty } —el producto más una cantidad—. Las tres acciones:

  • add: si el producto ya está en el carrito, sube su qty en 1; si no, agrega una línea nueva con qty: 1.
  • remove: si la línea tiene qty > 1, baja su qty en 1; si llega a 0, quita la línea entera.
  • clear: deja el carrito vacío.

Y todo sin mutar: cada rama devuelve estructuras nuevas (map, filter, spread), nunca modifica el state que recibió. Este es el reducer, tal como lo escribirás en React de verdad (aquí, JavaScript puro; en la lección 5 lo conecta useReducer):

// El cartReducer: una funcion PURA (state, action) => newState.
// El estado del carrito es un array de lineas: { id, name, priceCents, qty }.
function cartReducer(state, action) {
  switch (action.type) {
    case 'add': {
      const line = state.find((item) => item.id === action.product.id);
      if (line) {
        // ya esta en el carrito: sube la cantidad SIN mutar (map -> array nuevo)
        return state.map((item) =>
          item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item
        );
      }
      // producto nuevo: agrega una linea con qty 1 (array nuevo)
      return [...state, { ...action.product, qty: 1 }];
    }
    case 'remove': {
      const line = state.find((item) => item.id === action.id);
      if (line && line.qty > 1) {
        // baja la cantidad en 1 (sin mutar)
        return state.map((item) =>
          item.id === action.id ? { ...item, qty: item.qty - 1 } : item
        );
      }
      // qty llega a 0: quita la linea entera (filter -> array nuevo)
      return state.filter((item) => item.id !== action.id);
    }
    case 'clear':
      return [];
    default:
      return state;
  }
}

Léelo con la analogía. El switch (action.type) es el reglamento: una regla por tipo de jugada. Cada return produce un marcador nuevo —fíjate en state.map(...), [...state, ...], state.filter(...): todos crean arrays nuevos, ninguno toca el state de entrada—. Y el default: return state es la regla de oro de los reducers: ante una acción que no reconoce, devuelve el estado tal cual (nunca undefined, nunca revienta).

Ahora corrámoslo con una secuencia que ejercita las tres acciones y las cantidades. Agregamos utilidades para el total y para describir el carrito, y despachamos una jugada tras otra:

function cartTotal(state) {
  return state.reduce((sum, item) => sum + item.priceCents * item.qty, 0);
}
function formatPrice(cents) {
  return '$' + (cents / 100).toFixed(2);
}
function describe(state) {
  const lines = state.map((i) => `${i.name} x${i.qty}`).join(', ');
  return `[${lines}] total ${formatPrice(cartTotal(state))}`;
}

const MOUSE    = { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 };
const KEYBOARD = { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 };
const HUB      = { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 };

const actions = [
  { type: 'add', product: MOUSE },
  { type: 'add', product: KEYBOARD },
  { type: 'add', product: MOUSE },
  { type: 'remove', id: KEYBOARD.id },
  { type: 'add', product: HUB },
  { type: 'remove', id: MOUSE.id },
  { type: 'clear' },
];

console.log('=== cartReducer: (state, action) => newState ===\n');
let state = [];
console.log(`${'inicial'.padEnd(27)}${describe(state)}`);
for (const action of actions) {
  const before = state;
  state = cartReducer(state, action); // el marcador nuevo, calculado por el reglamento
  const label =
    action.type === 'add'
      ? `add ${action.product.name}`
      : action.type === 'remove'
      ? `remove ${(before.find((i) => i.id === action.id) || {}).name || action.id}`
      : 'clear';
  console.log(`${label.padEnd(27)}${describe(state)}`);
}

console.log('\n=== Pureza: el reducer NO muta el estado anterior ===');
const s1 = [{ ...MOUSE, qty: 1 }];
const s2 = cartReducer(s1, { type: 'add', product: MOUSE });
console.log(`s1 (anterior): ${describe(s1)}`);
console.log(`s2 (nuevo):    ${describe(s2)}`);
console.log(`misma referencia? ${s1 === s2}`);

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

=== cartReducer: (state, action) => newState ===

inicial                    [] total $0.00
add Wireless Mouse         [Wireless Mouse x1] total $25.99
add Mechanical Keyboard    [Wireless Mouse x1, Mechanical Keyboard x1] total $114.99
add Wireless Mouse         [Wireless Mouse x2, Mechanical Keyboard x1] total $140.98
remove Mechanical Keyboard [Wireless Mouse x2] total $51.98
add USB-C Hub              [Wireless Mouse x2, USB-C Hub x1] total $86.97
remove Wireless Mouse      [Wireless Mouse x1, USB-C Hub x1] total $60.98
clear                      [] total $0.00

=== Pureza: el reducer NO muta el estado anterior ===
s1 (anterior): [Wireless Mouse x1] total $25.99
s2 (nuevo):    [Wireless Mouse x2] total $51.98
misma referencia? false

Lee la secuencia jugada por jugada, verificando el reglamento.

  • inicial: carrito vacío, total $0.00.
  • add Wireless Mouse: el mouse no estaba → línea nueva Wireless Mouse x1. Total $25.99 (2599 centavos, formateados).
  • add Mechanical Keyboard: tampoco estaba → segunda línea. Total $114.99 (2599 + 8900).
  • add Wireless Mouse (otra vez): el mouse ya estaba → sube su cantidad a x2, sin duplicar la línea. Total $140.98 (2599×2 + 8900). Aquí ves la regla de add que no es un simple push.
  • remove Mechanical Keyboard: el keyboard tenía qty 1 → llega a 0 → se quita la línea entera. Queda Wireless Mouse x2, total $51.98 (2599×2).
  • add USB-C Hub: producto nuevo → tercera línea x1. Total $86.97 (5198 + 3499).
  • remove Wireless Mouse: el mouse tenía qty 2baja a x1 (no se quita, porque queda 1). Total $60.98 (2599 + 3499). Aquí ves la otra rama de remove: bajar cantidad, no borrar.
  • clear: carrito vacío, total $0.00.

Cada línea de salida es el reducer aplicando su reglamento a una jugada: subió cantidades, bajó cantidades, quitó líneas y vació, y el total en centavos cuadró en cada paso. Nunca improvisó; siempre (state, action) => newState.

Y mira la prueba de pureza al final, que es media lección. Tomamos un estado s1 con el mouse en qty 1, y le aplicamos add mouse. El resultado s2 tiene el mouse en qty 2 —correcto—. Pero fíjate en s1: sigue en qty 1. El reducer no tocó el estado que recibió; produjo uno nuevo. Y s1 === s2 es false: son objetos distintos, referencias distintas. Eso es exactamente lo que React necesita para "ver" el cambio (módulo 3: React compara referencias). Un reducer que hubiera hecho line.qty++ habría mutado s1, dejado la misma referencia, y React no habría re-renderizado. La pureza no es un lujo: es lo que hace que el reducer funcione con React.

Profundización: qué es un reducer y por qué se prueba tan fácil

La firma es el contrato entero: (state, action) => newState. Un reducer recibe dos cosas —el estado actual y una acción— y devuelve una: el estado nuevo. No recibe nada más, no devuelve nada más, no tiene efectos secundarios (no imprime, no llama a un servidor, no lee la hora). Todo lo que necesita para decidir viene en sus dos argumentos, y todo lo que produce sale en su return. Eso es una función pura, y de ahí sale todo lo bueno.

La acción describe qué pasó, no cómo cambiar el estado. Una acción es un objeto plano con un type (qué ocurrió: 'add', 'remove', 'clear') y los datos que ese tipo necesita (el product para add, el id para remove). El componente que despacha una acción no dice "sube la cantidad del mouse a 2"; dice "pasó un add del mouse", y el reducer decide qué significa eso. Esa separación —el que avisa qué pasó vs el que decide qué hacer— es la misma idea del "hijo avisa, padre decide" del módulo 4, ahora dentro de la lógica del estado.

Por qué un reducer se prueba sin navegador (y por qué eso importa aquí). Como el reducer es puro, probarlo es trivial: cartReducer([], { type: 'add', product: MOUSE }) y compara el resultado. No hay que renderizar nada, no hay que simular clics, no hay que montar React. Por eso en este módulo el reducer es la pieza que ejecutamos de verdad: es la lógica de la app en su forma más verificable. Si la lógica del carrito estuviera enredada en handlers que tocan el DOM, no podríamos probarla así. Sacarla a un reducer puro la vuelve testeable —y esa es una de las mejores razones para usar reducers, más allá de React—.

La regla dura: un reducer NUNCA muta el estado. Cada rama debe devolver una estructura nueva cuando cambia algo:

  • Cambiar un item → state.map(item => item.id === id ? { ...item, ...cambios } : item) (array nuevo, con el item cambiado copiado).
  • Agregar → [...state, nuevoItem] (array nuevo).
  • Quitar → state.filter(item => item.id !== id) (array nuevo).

Prohibido: state.push(...), item.qty++, state[0].qty = 2. Todo eso muta el estado viejo, deja la misma referencia, y React no ve el cambio. La inmutabilidad del módulo 3 no era una recomendación: en un reducer es la ley.

El default que devuelve state. Siempre hay una rama default que devuelve el estado sin cambios. Protege de acciones desconocidas (un type que no reconoces no revienta la app; simplemente no cambia nada) y es lo que React espera de un reducer bien formado. Nunca dejes que un reducer devuelva undefined.

Errores comunes

Mutar el estado en el reducer. Qué pasa: escribes state.push(newItem) en add, o line.qty++ en la rama de subir cantidad, y devuelves state. La UI no se actualiza. Por qué pasa: es la forma "natural" de JavaScript, y se olvida que React compara referencias. Cómo detectarlo: el reducer corre (lo ves con un log) pero la pantalla no cambia, porque devolviste la misma referencia que entró. Cómo corregirlo: nunca mutes; devuelve estructuras nuevas con map, filter, spread. Regla mecánica: si en tu reducer aparece push, pop, splice, sort sobre el estado, o un = que asigna a una propiedad del estado, está mal.

Meter efectos secundarios en el reducer. Qué pasa: dentro del cartReducer haces un fetch, un console.log "de producción", lees Date.now(), o generas un Math.random() para el id. El reducer deja de ser puro: la misma entrada ya no da la misma salida, y no se puede probar ni predecir. Por qué pasa: se ve el reducer como "un buen lugar para hacer cosas cuando pasa X". Cómo detectarlo: tu reducer depende de algo que no está en sus dos argumentos, o hace algo además de devolver el estado. Cómo corregirlo: el reducer solo calcula el estado nuevo. Los efectos (llamar al servidor, generar ids, loguear) van fuera —en el handler que arma la acción, o en un useEffect (módulo 6)—. La acción ya debe traer todo lo que el reducer necesita.

Olvidar el default (o no devolver el estado en algún caso). Qué pasa: una acción con un type que el switch no cubre cae fuera de todos los case, y el reducer devuelve undefined; el estado se pierde y la app revienta o se vacía. Por qué pasa: se agrega un type nuevo y se olvida su case, o se omite el default. Cómo detectarlo: tras cierta acción, el carrito queda undefined en vez de conservar sus items. Cómo corregirlo: siempre incluye default: return state. Un reducer debe devolver un estado válido para cualquier acción, aunque sea el mismo que entró.

Ejercicios

Ejercicio 1 — Predice la secuencia. Con el cartReducer del ejemplo (mismos productos), partiendo de un carrito vacío, aplica estas acciones en orden y escribe el estado y el total tras cada una: (1) add HUB; (2) add HUB; (3) add MOUSE; (4) remove HUB.

Ver solución
  • (1) add HUB: el hub no estaba → línea nueva USB-C Hub x1. Total $34.99 (3499).
  • (2) add HUB: ya estaba → sube a x2. Total $69.98 (3499×2).
  • (3) add MOUSE: nuevo → segunda línea. Estado [USB-C Hub x2, Wireless Mouse x1]. Total $95.97 (6998 + 2599).
  • (4) remove HUB: tenía qty 2 → baja a x1 (no se quita). Estado [USB-C Hub x1, Wireless Mouse x1]. Total $60.98 (3499 + 2599).

Lo clave: add sobre algo que ya está sube cantidad, y remove sobre algo con qty > 1 baja cantidad (solo quita la línea cuando la cantidad llegaría a 0).

Ejercicio 2 — Agrega una acción setQty. Diseña una acción nueva { type: 'setQty', id, qty } que fije la cantidad de una línea a un número exacto (por ejemplo, el usuario escribe "3" en el input de cantidad). Escribe el case 'setQty' del reducer, sin mutar, y decide qué pasa si qty es 0.

Ver solución
case 'setQty': {
  if (action.qty <= 0) {
    // fijar a 0 (o menos) equivale a quitar la linea
    return state.filter((item) => item.id !== action.id);
  }
  return state.map((item) =>
    item.id === action.id ? { ...item, qty: action.qty } : item
  );
}
  • Si qty <= 0, se quita la línea (filter → array nuevo): una cantidad de 0 no es una línea con qty 0, es no tener la línea. Esto mantiene el estado limpio (sin líneas fantasma en 0).
  • Si qty > 0, se fija con map, copiando el item cambiado ({ ...item, qty: action.qty }) y dejando los demás intactos. Array nuevo, sin mutar.

Nota que setQty no necesita saber la cantidad anterior: la fija, no la incrementa. La acción trae todo lo que el reducer necesita (id y qty).

Ejercicio 3 — Encuentra la mutación. Este intento de add está mal: la UI no se actualiza al agregar. Encuentra por qué y corrígelo sin mutar.

case 'add': {
  const line = state.find((item) => item.id === action.product.id);
  if (line) {
    line.qty++;        // (?)
    return state;      // (?)
  }
  state.push({ ...action.product, qty: 1 }); // (?)
  return state;        // (?)
}
Ver solución

Hay dos mutaciones, y las dos devuelven la misma referencia state, así que React no ve el cambio:

  1. line.qty++ muta el objeto de la línea existente (que vive dentro de state).
  2. state.push(...) muta el array state (le agrega un elemento en su lugar).

En ambos casos, return state devuelve el mismo array que entró: newState === oldState, y React no re-renderiza. La corrección es devolver estructuras nuevas:

case 'add': {
  const line = state.find((item) => item.id === action.product.id);
  if (line) {
    return state.map((item) =>
      item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item
    );
  }
  return [...state, { ...action.product, qty: 1 }];
}

Ahora map y el spread crean arrays nuevos, y el item cambiado se copia ({ ...item, qty: item.qty + 1 }) en vez de mutarse. Referencia nueva → React ve el cambio → re-render. Es el mismo add del ejemplo trabajado.

Resumen y siguiente paso

En esta lección construiste el cartReducer: una función pura (state, action) => newState que consolida toda la lógica del carrito —add (sube cantidad si el producto ya está, o agrega línea), remove (baja cantidad o quita la línea), clear— sin mutar nunca el estado. Lo anclaste con el marcador con reglamento: marcador actual + jugada → marcador nuevo, sin improvisar, sin recordar nada oculto, sin mirar afuera. Y lo mediste corriéndolo con una secuencia completa: el estado y el total en centavos cuadraron en cada jugada, y la prueba de pureza mostró que el estado anterior queda intacto (s1 === s2 es false). Viste por qué una función pura es el mejor amigo de quien verifica: se prueba con una entrada y una salida, sin navegador.

Antes de avanzar deberías poder: escribir un reducer con switch (action.type), cada rama devolviendo estructuras nuevas (sin mutar) y un default: return state; explicar por qué la pureza es lo que permite que React vea el cambio; y probar un reducer aislado con acciones sueltas.

La lección 5 conecta este reducer a React. Verás cómo const [cart, dispatch] = useReducer(cartReducer, []) mete el reducer dentro de un componente: el componente ya no lleva la lógica —solo despacha acciones con dispatch({ type: 'add', product })—, y React corre el reducer y re-renderiza con el estado nuevo. Lo ejecutarás con un mini-runtime de useReducer, y verás cuándo useReducer gana a useState (varios sub-valores que cambian juntos, transiciones con reglas) y cuándo useState sigue siendo lo simple y correcto.

Recursos