Módulo 4: Events And Handlers

Mini-proyecto: cablea los eventos del storefront

Descripción

Llegó el momento de juntar todo. En el módulo 1 armaste el storefront estático; en el 3 le pusiste estado; en este módulo le conectaste eventos, pieza por pieza. Ahora los cables se cruzan en una sola app que responde a un usuario de verdad: una SearchBar controlada que sube el texto al App, y ProductCard con un botón "Add to cart" que sube el producto al App, que llena el carrito de forma inmutable. Este mini-proyecto es la síntesis del módulo: el código React real de cada componente con sus handlers, el diagrama del flujo de datos (props bajan, callbacks suben), y la lógica ejecutada en Node —una sesión de usuario completa: teclear en la búsqueda, agregar un producto, limpiar, agregar otro— con su traza de renders y el total del carrito.

Conexión con el módulo. Es el capstone. Reúne los cuatro pilares: conectar eventos (L2), pasar la función sin llamarla (L3), el input controlado (L4-L5), y subir datos por callbacks (L6), con los handlers limpios de la L7. Y marca dos fronteras que verás cerrarse pronto: aquí la lista se filtra por el query (un adelanto de derivar, módulo 5) y el carrito vive en el App (un adelanto de levantar el estado, módulo 7). Este módulo se ocupa del cableado de los eventos; el diseño fino de esas dos piezas es de los módulos que siguen.

El plan: qué vamos a cablear

El storefront tiene dos interacciones, y las dos suben datos al App:

  1. Buscar. La SearchBar es un input controlado: su value sale del estado query (que vive en App), y cada tecla sube el texto nuevo al App por el callback onQueryChange. Con el query en el App, la lista de productos se filtra en vivo.
  2. Agregar al carrito. Cada ProductCard tiene un botón "Add to cart" que, al hacer clic, invoca el callback onAddToCart(product), subiendo el producto al App, que lo agrega al carrito sin mutar (creando un array nuevo, como en el módulo 3).

El App es el dueño de los dos estados (query y cart) y de los dos handlers. Los hijos solo avisan; el App decide.

El árbol y el flujo de datos

Este es el storefront con sus dos flujos: las props que bajan (continuas) y los datos que suben por callbacks (punteadas):

flowchart TD
    App["App  (estado: query, cart  ·  handlers)"]
    SB[SearchBar]
    PL[ProductList]
    PC1[ProductCard]
    PC2[ProductCard]
    Cart[Cart]

    App -- "query, onQueryChange" --> SB
    App -- "products, onAddToCart" --> PL
    App -- "cart" --> Cart
    PL --> PC1
    PL --> PC2

    SB -. "onQueryChange(text)" .-> App
    PC1 -. "onAddToCart(product)" .-> App
    PC2 -. "onAddToCart(product)" .-> App

Léelo en las dos direcciones. Hacia abajo (continuas): el App pasa el query y el onQueryChange a la SearchBar; los products y el onAddToCart a la ProductList (que reenvía onAddToCart a cada ProductCard); y el cart al Cart. Hacia arriba (punteadas): la SearchBar sube el texto tecleado, y cada ProductCard sube su producto. Todo lo que sube llega al App, el único dueño del estado. Los datos bajan por props; los datos suben por callbacks. Esa es la forma del módulo entero, dibujada.

El código React real

Este es el storefront completo tal como lo escribirías en React. Léelo componente por componente, notando dónde está cada pilar del módulo.

SearchBar — input controlado que sube el texto.

function SearchBar({ query, onQueryChange }) {
  return (
    <input
      value={query}                                 // controlado: value sale del estado
      onChange={(e) => onQueryChange(e.target.value)} // sube el texto al App (callback)
      placeholder="Search products..."
    />
  );
}

El value sale del query (que vive en App), y onChange sube cada tecla al App con onQueryChange. Fíjate: la SearchBar no tiene useState propio —el query vive en el App, y la barra solo lo muestra y lo sube—. Esto es un input controlado cuyo estado está arriba; el diseño de dónde vive ese estado es el módulo 7, pero el cableado del evento es de este módulo.

ProductCard — botón que sube el producto.

function ProductCard({ product, onAddToCart }) {
  return (
    <article className="product-card">
      <h3>{product.name}</h3>
      <p>{formatPrice(product.priceCents)}</p>
      <button onClick={() => onAddToCart(product)}>Add to cart</button>
    </article>
  );
}

El botón usa onClick={() => onAddToCart(product)}: la flecha pasa el argumento product sin llamar en el render (lección 3), subiendo el producto al App (lección 6).

ProductList — reenvía el callback a cada tarjeta.

function ProductList({ products, onAddToCart }) {
  return (
    <section className="product-list">
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={onAddToCart}   // reenvia el callback hacia abajo
        />
      ))}
    </section>
  );
}

ProductList está en medio: recibe onAddToCart del App y lo reenvía a cada ProductCard. Es el "nivel intermedio" que la lección 6 mencionaba; el nombre de la prop viaja intacto para que el callback llegue de arriba abajo. (El key es del módulo 2.)

App — dueño del estado y de los handlers.

function App() {
  const [query, setQuery] = useState('');
  const [cart, setCart] = useState([]);

  // el App decide que hacer con los datos que suben:
  function handleQueryChange(text) {
    setQuery(text);
  }
  function handleAddToCart(product) {
    setCart((prev) => [...prev, product]); // inmutable: array nuevo (modulo 3)
  }

  // la lista filtrada se DERIVA del estado (a fondo, modulo 5)
  const visible = PRODUCTS.filter((p) =>
    p.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div className="app">
      <SearchBar query={query} onQueryChange={handleQueryChange} />
      <ProductList products={visible} onAddToCart={handleAddToCart} />
      <Cart items={cart} />
    </div>
  );
}

El App tiene los dos estados (query, cart) y los dos handlers. handleAddToCart agrega sin mutar: [...prev, product] crea un array nuevo, para que React vea el cambio (módulo 3). Y la lista visible se deriva filtrando PRODUCTS por el query —no se guarda en otro estado—; ese es el adelanto del módulo 5. Nota cómo cada pilar del módulo aterriza aquí: el input controlado (query), los callbacks hacia arriba (onQueryChange, onAddToCart), pasar la función bien, y los handlers con nombre y limpios.

Ejemplo trabajado: una sesión de usuario, ejecutada

Vamos a correr el storefront simulando una sesión real: el usuario teclea "key" en la búsqueda (con lo que la lista se filtra al Mechanical Keyboard), agrega ese teclado al carrito, borra la búsqueda (la lista vuelve completa) y agrega el mouse. Modelamos los dos estados del App (query y cart) y los dos handlers, y en cada cambio "renderizamos": describimos la UI para el estado actual —la lista derivada y el total del carrito—.

// ---- estado de App (dos celdas: query y cart) ----
let query = '';
let cart = [];
const PRODUCTS = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 },
];
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
// ---- handlers que App pasa hacia abajo por props ----
function onQueryChange(value) { // lo llama SearchBar en cada tecla
  query = value;
  renderApp();
}
function onAddToCart(product) { // lo llama ProductCard en el click
  cart = [...cart, product];    // inmutable (modulo 3)
  renderApp();
}
// ---- "render": describe la UI para el estado actual (derivando la lista) ----
function renderApp() {
  const visible = PRODUCTS.filter((p) =>
    p.name.toLowerCase().includes(query.toLowerCase()));
  const names = visible.map((p) => p.name).join(', ');
  const cartTotal = cart.reduce((s, p) => s + p.priceCents, 0);
  console.log(`render | query="${query}" | lista: [${names}] | carrito(${cart.length}) total ${formatPrice(cartTotal)}`);
}
// ---- una sesion del usuario, modelada ----
console.log('=== El storefront cableado: buscar (controlado) + agregar (callback) ===\n');
renderApp(); // estado inicial
console.log('\nEl usuario teclea "key" en la SearchBar:');
for (const ch of 'key') onQueryChange(query + ch);
console.log('\nClick en "Add to cart" del Keyboard (unico visible):');
onAddToCart(PRODUCTS[1]);
console.log('\nEl usuario borra la busqueda:');
onQueryChange('');
console.log('\nClick en "Add to cart" del Mouse:');
onAddToCart(PRODUCTS[0]);
console.log(`\nCarrito final: [${cart.map((p) => p.name).join(', ')}]`);

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

=== El storefront cableado: buscar (controlado) + agregar (callback) ===

render | query="" | lista: [Wireless Mouse, Mechanical Keyboard, USB-C Hub] | carrito(0) total $0.00

El usuario teclea "key" en la SearchBar:
render | query="k" | lista: [Mechanical Keyboard] | carrito(0) total $0.00
render | query="ke" | lista: [Mechanical Keyboard] | carrito(0) total $0.00
render | query="key" | lista: [Mechanical Keyboard] | carrito(0) total $0.00

Click en "Add to cart" del Keyboard (unico visible):
render | query="key" | lista: [Mechanical Keyboard] | carrito(1) total $89.00

El usuario borra la busqueda:
render | query="" | lista: [Wireless Mouse, Mechanical Keyboard, USB-C Hub] | carrito(1) total $89.00

Click en "Add to cart" del Mouse:
render | query="" | lista: [Wireless Mouse, Mechanical Keyboard, USB-C Hub] | carrito(2) total $114.99

Carrito final: [Mechanical Keyboard, Wireless Mouse]

Lee la sesión de principio a fin, porque en ella está el módulo entero funcionando junto.

El estado inicial muestra query="", la lista completa (los tres productos) y el carrito vacío (total $0.00). Es la UI para el estado inicial: UI = f(estado).

Luego el usuario teclea "key", y cada tecla es una vuelta del input controlado (lección 5): query avanza "k" → "ke" → "key", y en cada render la lista se filtra al vuelo —de los tres productos solo queda Mechanical Keyboard, el único cuyo nombre contiene "key"—. Fíjate en que la lista no la "guardamos" en ningún lado: se deriva del query en cada render (adelanto del módulo 5). El texto subió de la SearchBar al App por onQueryChange, y el App lo usó para filtrar.

Con la lista filtrada al teclado, el usuario hace clic en "Add to cart" de esa tarjeta. El ProductCard sube el producto por onAddToCart(product) (lección 6); el App lo agrega al carrito sin mutar ([...cart, product]), y el render muestra carrito(1) total $89.00 —el precio del teclado, 8900 centavos, formateado—.

El usuario borra la búsqueda (onQueryChange('')): query vuelve a "", y la lista derivada vuelve a mostrar los tres productos. Nota que el carrito no se tocó: sigue en 1 item, $89.00. Buscar y el carrito son estados independientes; cambiar uno no afecta al otro.

Por último, el usuario agrega el mouse: el carrito pasa a 2 items, y el total sube a $114.99 (los 8900 del teclado más los 2599 del mouse). El Carrito final lista [Mechanical Keyboard, Wireless Mouse], en el orden en que se agregaron.

Esa es la interfaz viva completa: el usuario buscó, la lista reaccionó, agregó al carrito, el total se actualizó —y en ningún momento nosotros llamamos a un setter a mano—. Todo lo disparó el usuario a través de eventos, y cada dato subió al App por su callback. El módulo entero, en una sesión.

Errores comunes

Poner el useState del query en la SearchBar en vez del App. Qué pasa: la barra guarda el query en su propio estado, pero entonces el App (que filtra la lista) no lo ve, y la lista no reacciona a la búsqueda. Por qué pasa: parece natural que el estado del input viva "en el input". Cómo detectarlo: tecleas y la barra muestra el texto, pero la lista no se filtra. Cómo corregirlo: el query debe vivir en el ancestro común que lo necesita —el App, que filtra la lista—, y bajar a la SearchBar por props, subiendo cada tecla por onQueryChange. Este es justo el problema que resuelve levantar el estado, el módulo 7; aquí lo cableas correctamente aunque el "porqué" a fondo sea de allá.

Mutar el carrito al agregar (cart.push(product)). Qué pasa: agregas un producto pero la UI no se actualiza. Por qué pasa: push muta el array existente, así que la referencia no cambia, y React "no ve" el cambio (módulo 3). Cómo detectarlo: el carrito por dentro tiene el item nuevo, pero la pantalla sigue mostrando el conteo viejo. Cómo corregirlo: crea un array nuevo con setCart((prev) => [...prev, product]). Referencia nueva, render nuevo. La inmutabilidad del módulo 3 sigue siendo obligatoria cuando el estado es un array.

Reenviar mal el callback por el nivel intermedio. Qué pasa: el clic en ProductCard revienta con "onAddToCart is not a function", o no hace nada. Por qué pasa: ProductList (el nivel intermedio) no reenvió la prop onAddToCart a cada ProductCard, o la nombró distinto. Cómo detectarlo: props.onAddToCart es undefined dentro del ProductCard. Cómo corregirlo: asegúrate de que cada nivel intermedio reenvíe el callback con el mismo nombre (<ProductCard onAddToCart={onAddToCart} .../> dentro del map de ProductList). El callback tiene que llegar intacto de App hasta la hoja.

Ejercicios

Ejercicio 1 — Agrega "clear cart". Diseña un botón "Clear cart" que vacíe el carrito. Escribe (a) el handler handleClearCart que iría en el App, y (b) el JSX del botón, cuidando pasar la función sin llamarla. ¿Por qué setCart([]) respeta la inmutabilidad?

Ver solución

(a) El handler en el App:

function handleClearCart() {
  setCart([]);   // un array nuevo (vacio): referencia nueva
}

(b) El JSX del botón (por ejemplo, dentro del Cart o del App):

<button onClick={handleClearCart}>Clear cart</button>

Se pasa handleClearCart sin paréntesis —conectas la función, no la llamas (lección 3)—. Como no hay que pasar argumentos, no hace falta flecha.

setCart([]) respeta la inmutabilidad porque no muta el carrito viejo: le da a setCart un array nuevo (uno vacío). Es una referencia distinta de la anterior, así que React ve el cambio y re-renderiza con el carrito vacío. Vaciar con cart.length = 0 (mutación) dejaría la misma referencia y React no lo vería.

Ejercicio 2 — Traza una sesión nueva. Con el modelo del ejemplo trabajado (mismos PRODUCTS), el usuario: teclea "hub", agrega el único producto visible, y luego agrega también el mouse (sin borrar la búsqueda). Escribe la línea de render final: el query, la lista visible, el conteo del carrito y el total.

Ver solución

Paso a paso:

  • Teclea "hub": query llega a "hub". La lista se filtra a los productos cuyo nombre contiene "hub": solo USB-C Hub. (El nombre en minúsculas, "usb-c hub", contiene "hub".)
  • Agrega el único visible (USB-C Hub, 3499): carrito 1 item, total $34.99.
  • Agrega el mouse (Wireless Mouse, 2599) —aunque no esté visible en la lista filtrada, el ejercicio lo agrega directo—: carrito 2 items, total 3499 + 2599 = 6098$60.98.

El query no cambió al agregar (agregar al carrito no toca la búsqueda), así que sigue en "hub" y la lista sigue filtrada a USB-C Hub. La línea final:

render | query="hub" | lista: [USB-C Hub] | carrito(2) total $60.98

Lo importante: query y cart son estados independientes; agregar al carrito no altera la búsqueda, y la lista mostrada se deriva del query, no del carrito.

Ejercicio 3 — ¿Qué módulo resuelve qué? El storefront de este proyecto toca ideas de varios módulos. Para cada pieza, di de qué módulo es el concepto de fondo: (a) value={query} + onChange; (b) setCart((prev) => [...prev, product]) sin mutar; (c) const visible = PRODUCTS.filter(...) derivando la lista; (d) el cart viviendo en el App y bajando al Cart por props.

Ver solución
  • (a) value={query} + onChange — el input controlado, de este módulo (4), lección 5. El cableado del evento es lo que estás practicando.
  • (b) [...prev, product] sin mutar — la inmutabilidad del estado, del módulo 3. React compara referencias; un array nuevo dispara el render, una mutación no.
  • (c) PRODUCTS.filter(...) derivando la listaderivar en vez de guardar, del módulo 5. La lista filtrada se computa del estado (query), no se guarda en otro estado. Aquí es un adelanto.
  • (d) El cart en el App, bajando al Cartlevantar el estado, del módulo 7. El estado compartido vive en el ancestro común y baja por props. Aquí lo cableas; el diseño a fondo es allá.

El proyecto integra varios módulos, pero el foco de este es el cableado de los eventos: conectar, pasar la función, controlar el input y subir datos por callbacks. Las piezas (c) y (d) son puentes hacia lo que sigue.

Resumen y siguiente paso

En este mini-proyecto cablaste los eventos del storefront de punta a punta y viste el módulo entero funcionar junto. La SearchBar controlada sube cada tecla al App por onQueryChange; cada ProductCard sube su producto por onAddToCart(product); y el App —dueño de query y cartdecide: filtra la lista derivándola del query, y agrega al carrito sin mutar. Lo mediste en una sesión de usuario completa —teclear, filtrar, agregar, limpiar, agregar— con la traza de renders y el total del carrito ($89.00, luego $114.99), y en ningún momento llamaste a un setter a mano: todo lo disparó el usuario a través de eventos, y cada dato subió al App por su callback. Los cuatro pilares del módulo (conectar eventos, pasar la función, input controlado, callbacks hacia arriba) y los handlers limpios, en una sola app.

Antes de cerrar el módulo deberías poder: escribir el App con sus dos estados y dos handlers; cablear una SearchBar controlada y un ProductCard que sube su producto; reenviar un callback por un nivel intermedio (ProductList); agregar al carrito de forma inmutable; y distinguir qué es de este módulo (el cableado de eventos) de lo que es de los módulos 5 (derivar) y 7 (levantar el estado).

Y hacia dónde sigue la guía. Notaste dos cosas que este módulo cableó pero no explicó a fondo. Una: la lista visible se computa del query en cada render, en vez de guardarse —eso es derivar, no guardar, el módulo 5, que también profundiza en la key de las listas—. Dos: el cart y el query viven en el App y bajan a los hijos por props —eso es levantar el estado, el módulo 7, que explica por qué el estado compartido va en el ancestro común y cómo componer sin prop drilling—. Con los eventos dominados, ya tienes la interfaz viva; los módulos que siguen la vuelven correcta y organizada.

Recursos