Módulo 4: Events And Handlers

Pasar datos hacia arriba con callbacks

Descripción

Desde el módulo 1 sabes que los datos bajan: App conoce los productos y se los pasa a ProductList, que se los pasa a cada ProductCard, por props, de arriba hacia abajo. Pero la interactividad crea la necesidad opuesta. Cuando el usuario hace clic en "Add to cart" dentro de un ProductCard, el que tiene que enterarse —y decidir qué hacer— es el App, que está arriba. El clic pasa en el hijo; la decisión vive en el padre. ¿Cómo viaja el dato hacia arriba, si las props solo bajan y son de solo lectura? La respuesta es el patrón que cierra el módulo: los callbacks. Esta lección lo instala, lo aplica al onAddToCart de Mercado, y lo ejecuta viendo el dato subir de dos tarjetas distintas al mismo App.

Conexión con el módulo. Es la simetría que le faltaba al flujo de datos: la lección 1 lo anticipó (props bajan, callbacks suben) y aquí lo construimos. Usa todo lo anterior: un handler en el hijo (L2), que pasa el callback sin llamarlo o lo envuelve en una flecha para pasar el argumento (L3). Y deja una pregunta abierta a propósito —dónde vive el carrito que el App va llenando—, que es el corazón del módulo 7 (levantar el estado). Aquí el dato sube; el diseño del estado que lo recibe es allá.

Una analogía: el mesero que lleva tu pedido a la cocina

Estás en un restaurante, sentado en tu mesa. Quieres un plato, pero tú no cocinas —ni entras a la cocina, ni tienes las ollas—. Lo que haces es decirle al mesero "quiero la pasta". El mesero lleva tu pedido hacia la cocina, donde el chef —que sí tiene las ollas y decide cómo se prepara— lo recibe y actúa. Fíjate en el reparto: (la mesa) originas el pedido pero no lo ejecutas; el chef (la cocina) tiene el poder de hacerlo y decide; y el mesero es el canal que lleva tu pedido de la mesa a la cocina. Tú no invades la cocina; le avisas al mesero, y la cocina hace lo suyo.

En React, la mesa es el ProductCard (el hijo, donde ocurre el clic). La cocina es el App (el padre, que tiene el estado del carrito y decide qué hacer). Y el mesero es el callback: onAddToCart, una función que el App le presta al ProductCard por props. Cuando el usuario hace clic en "Add to cart", el ProductCard no toca el carrito directamente —no puede, el carrito no es suyo—; invoca al mesero con su pedido: onAddToCart(product). Ese dato (el producto) viaja hacia arriba, hasta el App, que lo recibe y decide agregarlo al carrito. El hijo avisa; el padre decide.

Y aquí está el detalle que hace elegante al patrón: el mesero no sabe cocinar, y no le importa. El ProductCard no sabe qué hará el App con el producto —¿agregarlo al carrito?, ¿mostrar un aviso?, ¿registrar una analítica?—; su único trabajo es avisar con el dato correcto. Toda la decisión vive en el padre. Eso hace al ProductCard reutilizable: la misma tarjeta sirve en la tienda, en los recomendados o en una wishlist, porque no está atada a una cocina —recibe su mesero por props y le avisa, sea cual sea el padre—.

Ejemplo trabajado: el dato sube de dos tarjetas al mismo App

Vamos a ver el dato subir. Montamos un App (el padre) con un handler handleAddToCart que recibe un producto y lo agrega a su carrito, y se lo pasa a dos ProductCard distintas por la prop onAddToCart. Cada tarjeta, al "hacer clic", invoca ese callback con su producto. Primero, cómo se ve en React de verdad:

// EL HIJO: recibe onAddToCart por props y lo invoca con su producto al hacer click.
function ProductCard({ product, onAddToCart }) {
  return (
    <article>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product)}>Add to cart</button>
    </article>
  );
}

// EL PADRE: da la funcion por props y decide que hacer con el dato que sube.
function App() {
  function handleAddToCart(product) {
    // aqui el App decide: agregar al carrito (el estado del carrito es el modulo 7)
    console.log('Agregado:', product.name);
  }
  return (
    <ProductList products={PRODUCTS} onAddToCart={handleAddToCart} />
  );
}

Léelo con la analogía. handleAddToCart es la cocina: vive en el App, recibe el producto y decide. onAddToCart={handleAddToCart} le presta ese mesero al ProductCard (bajando por ProductList). Y en el hijo, onClick={() => onAddToCart(product)} es el pedido: al hacer clic, el ProductCard invoca al mesero con su producto. Fíjate en la flecha —() => onAddToCart(product)—: la necesitamos para pasar el argumento product sin llamar a onAddToCart en el render (justo el patrón de la lección 3).

Ahora ejecutémoslo. Modelamos el clic como la invocación del onClick (como en toda la lección), y hacemos que el App vaya registrando lo que recibe:

function h(tag, props, ...children) {
  return { tag, props: props || {}, children: children.flat() };
}
// EL HIJO: recibe onAddToCart por props. No sabe QUE hace el padre con el dato;
// solo lo INVOCA con su producto cuando el usuario hace click.
function ProductCard(props) {
  function handleAddClick() {
    props.onAddToCart(props.product); // el dato SUBE al padre
  }
  return h('article', {},
    h('h3', {}, props.product.name),
    h('button', { onClick: handleAddClick }, 'Add to cart')
  );
}
// EL PADRE: da la funcion por props y DECIDE que hacer con el dato que sube.
const cart = [];
function handleAddToCart(product) {
  cart.push(product);
  console.log(`  [App] recibio "${product.name}" -> carrito ahora tiene ${cart.length} item(s)`);
}
const mouse    = { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 };
const keyboard = { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 };
// El padre construye las tarjetas y les BAJA la funcion por la prop onAddToCart.
const card1 = ProductCard({ product: mouse,    onAddToCart: handleAddToCart });
const card2 = ProductCard({ product: keyboard, onAddToCart: handleAddToCart });
console.log('=== El dato sube: hijo invoca el callback, padre decide ===\n');
console.log('Click en la tarjeta del Mouse:');
card1.children[1].props.onClick();
console.log('Click en la tarjeta del Keyboard:');
card2.children[1].props.onClick();
console.log('Click en la tarjeta del Mouse otra vez:');
card1.children[1].props.onClick();
console.log(`\nCarrito final: [${cart.map((p) => p.name).join(', ')}]`);

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

=== El dato sube: hijo invoca el callback, padre decide ===

Click en la tarjeta del Mouse:
  [App] recibio "Wireless Mouse" -> carrito ahora tiene 1 item(s)
Click en la tarjeta del Keyboard:
  [App] recibio "Mechanical Keyboard" -> carrito ahora tiene 2 item(s)
Click en la tarjeta del Mouse otra vez:
  [App] recibio "Wireless Mouse" -> carrito ahora tiene 3 item(s)

Carrito final: [Wireless Mouse, Mechanical Keyboard, Wireless Mouse]

Lee la salida siguiendo el viaje del dato.

Cada "clic" ocurre en una tarjeta (el hijo), pero quien imprime es [App] (el padre). Eso es el dato subiendo: el clic pasó abajo, pero la acción —recibir el producto y crecer el carrito— pasó arriba. En el primer clic, card1 (la del mouse) invocó onAddToCart(mouse), y el App recibió "Wireless Mouse" y su carrito pasó a 1. En el segundo, card2 (la del keyboard) invocó onAddToCart(keyboard), y el mismo App recibió "Mechanical Keyboard" y su carrito pasó a 2. Fíjate: dos tarjetas distintas subieron su producto al mismo App, porque ambas recibieron el mismo mesero (handleAddToCart) por props. El tercer clic (otra vez el mouse) llega a 3, y el Carrito final lista los tres productos en orden.

Detente en el reparto de responsabilidades, que es la lección entera. El ProductCard no sabe qué es un carrito ni qué hace handleAddToCart; solo invoca onAddToCart con su product. El App no sabe qué tarjeta hizo clic; solo recibe un producto y decide agregarlo. El hijo avisa; el padre decide. Y el canal entre los dos es la función que el padre prestó por props: el mesero.

Profundización: la simetría del flujo de datos

Los datos bajan por props; los datos suben por llamadas a callbacks. Esta es la frase que resume el modelo de datos de React, y ahora la tienes completa:

flowchart TD
    App["App (tiene el estado + los handlers)"]
    PC["ProductCard (hijo)"]
    App -- "product, onAddToCart (props BAJAN)" --> PC
    PC -. "onAddToCart(product)  (dato SUBE)" .-> App

La flecha continua es lo de siempre: App le pasa a ProductCard sus datos (product) y también la función onAddToCart, todo por props, hacia abajo. La flecha punteada es la novedad: cuando el usuario actúa, el ProductCard invoca esa función con su dato, y el dato viaja hacia arriba. Nota algo elegante: la función misma bajó como una prop cualquiera (una prop puede ser un número, un string… o una función); lo que sube es el dato con que se la invoca. No se rompe la regla de que las props bajan: el callback baja como prop, y su invocación sube el dato.

Por qué el hijo no puede "alcanzar" al padre directamente. Las props son de solo lectura (módulo 1): un ProductCard no puede modificar los datos de su padre ni "meterle" un producto al carrito del App. Lo único que puede hacer es pedírselo, invocando una función que el padre le prestó. Esta restricción —que al principio parece un estorbo— es lo que mantiene el flujo predecible: el estado solo lo cambia su dueño, y los hijos solo pueden solicitar cambios. Sabes exactamente dónde se cambia cada cosa: en el padre que tiene el handler.

Nombrar los callbacks: onAlgo en las props, handleAlgo en el padre. El App define handleAddToCart (su función) y la pasa como la prop onAddToCart del hijo. La convención es clara: en la interfaz del hijo, la prop se llama onX (como los eventos nativos onClick); en el padre, la función se llama handleX. Así, <ProductCard onAddToCart={handleAddToCart} /> se lee natural: "cuando ocurra el 'add to cart', maneja con handleAddToCart". Un hijo bien diseñado documenta sus callbacks como parte de sus props, igual que sus datos.

El hijo reutilizable no conoce a su padre. Como el ProductCard solo invoca onAddToCart(product) sin saber qué hace el padre, la misma tarjeta funciona bajo cualquier padre que le pase un onAddToCart. En la tienda, el padre agrega al carrito; en una página de recomendados, el mismo ProductCard podría recibir un onAddToCart que agregue a una wishlist. El hijo no cambia; cambia el mesero que le prestan. Esto es reuso real, y es la razón por la que el patrón de callbacks se prefiere a que el hijo "sepa" del carrito.

Errores comunes

Intentar que el hijo modifique el estado del padre directamente. Qué pasa: dentro del ProductCard, alguien intenta props.cart.push(product) o reasignar algo del padre. Por qué pasa: se olvida que las props son de solo lectura y se busca el camino "directo". Cómo detectarlo: mutas algo que llegó por props y la UI no se actualiza (o React se queja), porque no pasaste por un setter del dueño. Cómo corregirlo: el hijo no toca el estado del padre; lo avisa por un callback (onAddToCart(product)), y el padre —dueño del estado— decide y lo cambia con su setter. Hijo avisa, padre decide.

Llamar el callback en el render (onClick={onAddToCart(product)}). Qué pasa: al pintarse la tarjeta, el producto se "agrega" solo, sin clic; y el clic real no hace nada. Por qué pasa: la trampa de la lección 3 —los paréntesis ejecutan onAddToCart(product) en el render—. Cómo detectarlo: el carrito crece apenas aparece la lista, sin que nadie haga clic (y si el handler cambia estado, puedes provocar un bucle). Cómo corregirlo: envuelve en una flecha para diferir la llamada hasta el clic: onClick={() => onAddToCart(product)}. Así pasas el argumento sin ejecutar en el render.

Olvidar pasar el callback (o pasarlo con otro nombre). Qué pasa: el hijo hace clic y no pasa nada, o revienta con "onAddToCart is not a function". Por qué pasa: el padre no incluyó la prop onAddToCart, o la nombró distinto de como el hijo la espera. Cómo detectarlo: props.onAddToCart es undefined en el hijo. Cómo corregirlo: asegúrate de que el padre pase la prop con el mismo nombre que el hijo usa (<ProductCard onAddToCart={handleAddToCart} />), y de que ese nombre viaje intacto por los niveles intermedios (si ProductList está en medio, tiene que reenviar la prop hacia abajo).

Ejercicios

Ejercicio 1 — ¿Quién avisa y quién decide? En el patrón onAddToCart, identifica: (a) quién origina el clic; (b) quién tiene el estado del carrito y decide; (c) qué es exactamente onAddToCart y en qué dirección viaja como prop; (d) en qué dirección viaja el product.

Ver solución
  • (a) Origina el clic: el ProductCard (el hijo). El botón "Add to cart" está en la tarjeta, y ahí ocurre el evento.
  • (b) Tiene el estado y decide: el App (el padre). El carrito es su estado, y handleAddToCart —su función— decide agregar el producto.
  • (c) onAddToCart es una función (el callback / el "mesero") que el padre define (handleAddToCart) y le pasa al hijo por props, es decir, viaja hacia abajo, como cualquier prop.
  • (d) El product viaja hacia arriba: cuando el hijo invoca onAddToCart(product), ese dato sube del ProductCard al App.

En una frase: la función baja como prop; el dato sube cuando el hijo la invoca. El hijo avisa; el padre decide.

Ejercicio 2 — Conecta el mesero. Este ProductCard recibe onAddToCart pero no lo usa. Conéctalo al botón para que, al hacer clic, suba el product al padre. Cuida el detalle de pasar el argumento sin llamar en el render.

function ProductCard({ product, onAddToCart }) {
  return (
    <article>
      <h3>{product.name}</h3>
      <button>Add to cart</button>
    </article>
  );
}
Ver solución

Hay que conectar el onClick del botón para que invoque onAddToCart(product). Como necesitas pasar el argumento product, envuélvelo en una flecha:

function ProductCard({ product, onAddToCart }) {
  return (
    <article>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product)}>Add to cart</button>
    </article>
  );
}
  • onClick={() => onAddToCart(product)}: al hacer clic, la flecha se ejecuta y llama a onAddToCart(product), subiendo el producto al padre.
  • La flecha es necesaria por dos razones: pasar el argumento product, y no llamar a onAddToCart en el render (que sería el bug de la lección 3). onClick={onAddToCart(product)} estaría mal.

Ejercicio 3 — El mismo hijo, dos padres. Explica por qué el mismo ProductCard (sin cambiarle una línea) puede usarse en la tienda (donde el clic agrega al carrito) y en una página de recomendados (donde el clic agrega a una wishlist). ¿Qué es lo que cambia entre los dos usos?

Ver solución

El ProductCard no cambia porque no sabe qué hace el padre con el producto: su único trabajo es invocar onAddToCart(product) cuando el usuario hace clic. No conoce el carrito, ni la wishlist, ni ninguna cocina en particular; solo avisa con el dato correcto.

Lo que cambia entre los dos usos es el callback que el padre le presta (el mesero):

// En la tienda:
<ProductCard product={p} onAddToCart={handleAddToCart} />   // agrega al carrito

// En recomendados:
<ProductCard product={p} onAddToCart={handleAddToWishlist} /> // agrega a la wishlist

Es el mismo hijo con distinto mesero. Como toda la decisión vive en el padre y baja por props, el hijo es reutilizable sin tocarlo. Esa independencia es la razón principal por la que se prefiere el patrón de callbacks a que el hijo "sepa" del carrito.

Resumen y siguiente paso

En esta lección completaste la simetría del flujo de datos: los datos bajan por props; los datos suben por llamadas a callbacks. Cuando un dato tiene que viajar de un hijo a un padre —el product que un ProductCard quiere agregar—, el padre le presta al hijo una función por props (onAddToCart, el mesero), el hijo la invoca con su dato al ocurrir el evento (onAddToCart(product)), y el padre —dueño del estado— decide qué hacer. Lo mediste: dos tarjetas distintas subieron su producto al mismo App, que fue llenando su carrito, mientras cada clic ocurría abajo y cada decisión pasaba arriba. Y viste por qué el patrón es elegante: el hijo no conoce a su padre, así que la misma tarjeta se reutiliza bajo cualquier padre que le pase un onAddToCart.

Antes de avanzar deberías poder: explicar cómo sube un dato en React y por qué las props (de solo lectura) no lo impiden; escribir el onClick={() => onAddToCart(product)} de un hijo que sube su dato; nombrar bien un callback (onX en el hijo, handleX en el padre); y decir qué separa este módulo del 7 (aquí el dato sube; dónde vive el estado que lo recibe es allá).

La lección 7 cierra los temas del módulo con una cuestión de oficio: mantener los handlers limpios. Vas a ver por qué meter lógica pesada (validaciones, normalización, varios pasos) directamente en el JSX vuelve el marcado ilegible, y cómo sacarla a una función con nombre —como un handleSubmit que hace preventDefault, limpia el texto, ignora búsquedas vacías y avisa al padre— deja el JSX legible y la lógica probable. Con eso, tendrás los cuatro pilares del módulo listos para el mini-proyecto.

Recursos