Módulo 2: Jsx And Rendering

Mini-proyecto: renderiza el catálogo de Mercado

Descripción

Es hora de juntar todo. En este módulo desmitificaste JSX (es JavaScript), aprendiste los atributos (className, htmlFor, booleanos), el renderizado condicional (&&, ternario, early return), las listas con .map() y la key estable, y la postura declarativa que lo une. Este mini-proyecto los aplica a la vez para construir una pieza real del storefront de Mercado: el catálogo —la barra de búsqueda arriba y la lista de productos debajo, con sus badges de "agotado" y su estado de "sin resultados"—.

No hay concepto nuevo. Hay integración: ver cómo las cinco herramientas conviven en un componente que se parece a lo que escribirías en un trabajo real. Lo construyes con JSX real (la API que usarás) y lo ejecutas en Node para ver el HTML completo salir con datos y con la lista vacía —cerrando la promesa del módulo: nada se cita de memoria, todo se ejecuta—.

Conexión con el módulo. Es el capstone: reúne las lecciones 2 a 7 en un solo entregable. El SearchBar usa los atributos de la lección 3; el ProductCard usa el condicional de la 4 (badge con &&, texto con ternario); el ProductList usa el .map() de la 5 con la key de la 6; y el StorefrontCatalog maneja el "sin resultados" con el early return de la 4. Todo descrito, no manipulado (lección 7). Y marca la frontera con lo que viene: aquí el catálogo renderiza una lista de productos que le damos; que esa lista cambie sola cuando el usuario escriba (el estado, módulo 3), que el <input> responda al tecleo (los eventos, módulo 4), y que la lista se filtre por la búsqueda (el estado derivado, módulo 5) es lo próximo. Hoy montas la foto fija, completa y correcta.

El árbol de componentes del catálogo

Antes de escribir, ten el mapa de lo que vas a construir. El catálogo es este árbol:

flowchart TD
    Catalog["StorefrontCatalog"] --> SearchBar["SearchBar"]
    Catalog --> ProductList["ProductList"]
    ProductList --> PC1["ProductCard (p1)"]
    ProductList --> PC2["ProductCard (p2)"]
    ProductList --> PC3["ProductCard (p3)"]

StorefrontCatalog en la raíz contiene el SearchBar (la barra de búsqueda) y el ProductList (la lista). ProductList contiene un ProductCard por producto —o, si la lista viene vacía, el estado "sin resultados" en lugar de las tarjetas—. Es la mitad de arriba del storefront que dibujaste en el módulo 1; el Cart (la mitad de abajo) llegará cuando tengamos estado y eventos para manejarlo.

En ASCII, para tenerlo a mano:

StorefrontCatalog
├── SearchBar            (label htmlFor + input: leccion 3)
└── ProductList          (early return si vacia: leccion 4)
    ├── ProductCard      (.map con key: lecciones 5-6)
    │     └── badge "Sold out" si !inStock   (&&: leccion 4)
    ├── ProductCard
    └── ProductCard

Una analogía: armar el mueble con las piezas que ya fabricaste

Piensa en un mueble modular que compras por partes. Durante el módulo fabricaste cada pieza por separado: la puerta (el SearchBar), los estantes (los ProductCard), el marco que los sostiene (el ProductList), y aprendiste los herrajes (los atributos, el condicional, la key). El mini-proyecto es el momento de ensamblar: no fabricas nada nuevo, tomas las piezas probadas y las unes siguiendo el plano (el árbol de componentes). Si cada pieza está bien hecha —y la probaste, lección por lección—, el ensamble encaja. Y como todo es declarativo, "ensamblar" es solo describir cómo se compone el catálogo a partir de las piezas; no atornillas nada a mano. Guarda la imagen: piezas probadas, un plano, y el ensamble como descripción.

Ejemplo trabajado: el catálogo completo

Construimos las cuatro piezas y las ensamblamos. Primero, en JSX real —la API que escribirás—:

function SearchBar(props) {
  return (
    <div className="search-bar">
      <label htmlFor="product-search">Search products</label>
      <input
        id="product-search"
        type="search"
        className="search-input"
        placeholder="Search products..."
        value={props.query}
      />
    </div>
  );
}

function ProductCard(props) {
  return (
    <article className="product-card">
      <h3 className="product-name">{props.name}</h3>
      <p className="product-price">{formatPrice(props.priceCents)}</p>
      <span className="product-stock">
        {props.inStock ? 'In stock' : 'Out of stock'}
      </span>
      {!props.inStock && <span className="badge badge-out">Sold out</span>}
    </article>
  );
}

function ProductList(props) {
  if (props.products.length === 0) {
    return <p className="empty-state">No products match your search</p>;
  }
  return (
    <section className="product-list">
      {props.products.map((product) => (
        <ProductCard
          key={product.id}
          name={product.name}
          priceCents={product.priceCents}
          inStock={product.inStock}
        />
      ))}
    </section>
  );
}

function StorefrontCatalog(props) {
  return (
    <main className="catalog">
      <SearchBar query={props.query} />
      <ProductList products={props.products} />
    </main>
  );
}

Reconoce cada herramienta del módulo en su lugar: en SearchBar, el htmlFor que apunta al id del <input> y el value={props.query} como expresión (lección 3). En ProductCard, el ternario del stock y el badge con && (lección 4). En ProductList, el early return del "sin resultados" (lección 4) y el .map() con key={product.id} (lecciones 5 y 6). En StorefrontCatalog, la composición que baja query y products por props (el flujo del módulo 1). Nada nuevo; todo integrado.

Ahora la versión ejecutable en Node, que renderiza el catálogo dos veces —con productos y con la lista vacía—:

function h(tag, props, ...children) {
  return { tag, props: props || {}, children: children.flat() };
}
const VOID = new Set(['input', 'img', 'br', 'hr']);
const ATTR = { className: 'class', htmlFor: 'for' };
function renderToString(node, indent = 0) {
  const pad = '  '.repeat(indent);
  if (node == null || typeof node === 'boolean') return '';
  if (typeof node !== 'object') return pad + node;
  const attrs = Object.entries(node.props)
    .filter(([, v]) => v !== false && v != null)
    .map(([k, v]) => (v === true ? ` ${ATTR[k] || k}` : ` ${ATTR[k] || k}="${v}"`))
    .join('');
  if (VOID.has(node.tag)) return `${pad}<${node.tag}${attrs} />`;
  const kids = node.children.filter((c) => c != null && c !== false);
  if (kids.length <= 1 && kids.every((c) => typeof c !== 'object'))
    return `${pad}<${node.tag}${attrs}>${kids[0] ?? ''}</${node.tag}>`;
  const inner = kids.map((c) => renderToString(c, indent + 1)).join('\n');
  return `${pad}<${node.tag}${attrs}>\n${inner}\n${pad}</${node.tag}>`;
}
function formatPrice(cents) {
  return '$' + (cents / 100).toFixed(2);
}

function SearchBar(props) {
  return h('div', { className: 'search-bar' },
    h('label', { htmlFor: 'product-search' }, 'Search products'),
    h('input', {
      id: 'product-search',
      type: 'search',
      className: 'search-input',
      placeholder: 'Search products...',
      value: props.query,
    })
  );
}

function ProductCard(props) {
  return h('article', { className: 'product-card' },
    h('h3', { className: 'product-name' }, props.name),
    h('p', { className: 'product-price' }, formatPrice(props.priceCents)),
    h('span', { className: 'product-stock' },
      props.inStock ? 'In stock' : 'Out of stock'),
    !props.inStock && h('span', { className: 'badge badge-out' }, 'Sold out')
  );
}

function ProductList(props) {
  if (props.products.length === 0) {
    return h('p', { className: 'empty-state' }, 'No products match your search');
  }
  return h('section', { className: 'product-list' },
    props.products.map((p) => ProductCard(p))
  );
}

function StorefrontCatalog(props) {
  return h('main', { className: 'catalog' },
    SearchBar({ query: props.query }),
    ProductList({ products: props.products })
  );
}

const products = [
  { id: 'p1', name: 'Wireless Mouse', priceCents: 2599, inStock: true },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, inStock: false },
  { id: 'p3', name: 'USB-C Hub', priceCents: 3499, inStock: true },
];

console.log('=== Catalogo con productos ===');
console.log(renderToString(StorefrontCatalog({ query: '', products })));

console.log('\n=== Catalogo sin resultados (lista vacia) ===');
console.log(renderToString(StorefrontCatalog({ query: 'laptop', products: [] })));

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

=== Catalogo con productos ===
<main class="catalog">
  <div class="search-bar">
    <label for="product-search">Search products</label>
    <input id="product-search" type="search" class="search-input" placeholder="Search products..." value="" />
  </div>
  <section class="product-list">
    <article class="product-card">
      <h3 class="product-name">Wireless Mouse</h3>
      <p class="product-price">$25.99</p>
      <span class="product-stock">In stock</span>
    </article>
    <article class="product-card">
      <h3 class="product-name">Mechanical Keyboard</h3>
      <p class="product-price">$89.00</p>
      <span class="product-stock">Out of stock</span>
      <span class="badge badge-out">Sold out</span>
    </article>
    <article class="product-card">
      <h3 class="product-name">USB-C Hub</h3>
      <p class="product-price">$34.99</p>
      <span class="product-stock">In stock</span>
    </article>
  </section>
</main>

=== Catalogo sin resultados (lista vacia) ===
<main class="catalog">
  <div class="search-bar">
    <label for="product-search">Search products</label>
    <input id="product-search" type="search" class="search-input" placeholder="Search products..." value="laptop" />
  </div>
  <p class="empty-state">No products match your search</p>
</main>

Recorre la salida y localiza cada herramienta del módulo en el HTML:

  • Los atributos (lección 3). En el <div class="search-bar">, el className salió como class, y el htmlFor del label como for="product-search", apuntando al id del <input ... /> (un elemento void, sin cierre). El value refleja la expresión: "" en el primer render, "laptop" en el segundo.
  • El condicional (lección 4). El Mechanical Keyboard (agotado) muestra "Out of stock" (ternario) y el <span class="badge badge-out">Sold out</span> (el &&); los otros dos, en stock, muestran "In stock" y sin badge. Y en el segundo render, el early return produjo <p class="empty-state">No products match your search</p> en lugar de la <section>.
  • Las listas y la key (lecciones 5-6). La <section class="product-list"> contiene tres <article>, uno por producto, en el orden del array —el .map() en acción—. La key={product.id} está en el JSX (no aparece en el HTML porque es de uso interno de React, pero es la identidad estable que cada tarjeta lleva).
  • La composición y el flujo declarativo (módulos 1 y 7). StorefrontCatalog bajó query al SearchBar y products al ProductList por props; cada pieza describió su parte, y el HTML anidado es el árbol de componentes hecho texto. Nadie manipuló una pantalla: dos llamadas con datos distintos produjeron dos descripciones completas.

Ese HTML es el catálogo del storefront de Mercado, renderizado con las herramientas del módulo, ejecutado y verificado.

Errores comunes

Perder la key al mover el .map() entre componentes. Qué pasa: al refactorizar (extraer ProductList, mover el .map()), la key se queda en un nivel que ya no es el de la lista, y vuelve la advertencia. Por qué pasa: la key es fácil de "traspapelar" al reorganizar. Cómo detectarlo: React avisa "unique key prop" aunque "ya estaba". Cómo corregirlo: la key va en el elemento que el .map() devuelve directamente —aquí, el <ProductCard>—, no en el <article> interno ni en el <section>. Después de cualquier refactor, verifica que sigue ahí.

Olvidar uno de los estados del catálogo. Qué pasa: se prueba solo con datos y se olvida el caso de lista vacía, así que en producción una búsqueda sin resultados deja la pantalla en blanco. Por qué pasa: durante el desarrollo casi siempre hay productos. Cómo detectarlo: filtras por algo inexistente y no aparece ningún mensaje. Cómo corregirlo: prueba el catálogo con ambos datos —lista con productos y lista vacía—, como hicimos en el ejemplo (dos renders). El early return del "sin resultados" es parte del componente, no un extra.

Mezclar la lógica de filtrado dentro del render "porque cabe". Qué pasa: con las ganas de "hacerlo completo", se mete un products.filter(p => p.name.includes(query)) dentro del ProductList para filtrar por la búsqueda. Por qué pasa: técnicamente .filter() es una expresión y cabe. Cómo detectarlo: tu ProductList empieza a decidir qué productos mostrar, no solo cómo mostrarlos. Cómo corregirlo: respeta la frontera del módulo. Aquí el catálogo renderiza los productos que le dan; derivar la lista filtrada desde query es estado derivado (módulo 5), y vive mejor arriba (en App), no dentro de la lista. Mantén ProductList enfocado en renderizar la lista que recibe. (Por eso el "sin resultados" se dispara con una lista vacía dada, no con un filtro hecho aquí.)

Ejercicios

Ejercicio 1 — Agrega el precio de oferta. Extiende ProductCard para que, si el producto trae salePriceCents (un número) además de priceCents, muestre el precio de oferta con la clase on-sale y un badge "Sale"; si salePriceCents es null o no viene, muestra el precio normal sin badge. Usa las herramientas del módulo.

Ver solución
function ProductCard(props) {
  const onSale = props.salePriceCents != null;
  return (
    <article className="product-card">
      <h3 className="product-name">{props.name}</h3>
      <p className={onSale ? 'product-price on-sale' : 'product-price'}>
        {formatPrice(onSale ? props.salePriceCents : props.priceCents)}
      </p>
      {onSale && <span className="badge badge-sale">Sale</span>}
      <span className="product-stock">
        {props.inStock ? 'In stock' : 'Out of stock'}
      </span>
      {!props.inStock && <span className="badge badge-out">Sold out</span>}
    </article>
  );
}

Calculamos onSale una vez (una sentencia antes del return, lección 2). La clase del precio usa un ternario (lección 3/4): on-sale o no. El texto usa otro ternario para elegir qué precio formatear. El badge "Sale" usa &&: existe solo si onSale. Y convive con el badge "Sold out", cada uno con su condición. Todo declarativo: describimos cómo se ve la tarjeta para un producto en oferta y para uno normal, sin manipular nada.

Ejercicio 2 — El contador de resultados. Agrega, dentro de StorefrontCatalog y encima de la lista, un <p className="result-count"> que diga cuántos productos hay: "3 products" si hay tres, "1 product" (singular) si hay uno, y nada (no muestres el contador) si la lista está vacía. Escribe el fragmento en JSX.

Ver solución
function StorefrontCatalog(props) {
  const count = props.products.length;
  return (
    <main className="catalog">
      <SearchBar query={props.query} />
      {count > 0 && (
        <p className="result-count">
          {count} {count === 1 ? 'product' : 'products'}
        </p>
      )}
      <ProductList products={props.products} />
    </main>
  );
}

count se calcula arriba. El contador usa && con una condición booleana (count > 0), no count a secas —para no caer en la trampa del 0 fantasma de la lección 4: si la lista está vacía, count > 0 es false y no se muestra nada—. El singular/plural sale de un ternario (count === 1 ? 'product' : 'products'). Cuando hay resultados, se ve "3 products" o "1 product"; cuando no, el contador simplemente no existe.

Ejercicio 3 — Traza el flujo y los estados. Sin correr nada, responde: (a) en el render "con productos", ¿qué props recibe exactamente el segundo ProductCard (el del teclado), y por qué termina con badge? (b) En el render "sin resultados", ¿por qué el HTML no tiene ninguna <section class="product-list">? (c) ¿Dónde está la key de cada ProductCard y por qué no aparece en el HTML de salida?

Ver solución
  • (a) El segundo ProductCard recibe name: 'Mechanical Keyboard', priceCents: 8900, inStock: false (y key: 'p2'), porque ProductList hizo .map() sobre products y le pasó los campos del segundo producto. Termina con badge porque inStock es false: el ternario del stock da "Out of stock", y el && (!false && <span>...) produce el <span class="badge badge-out">Sold out</span>.
  • (b) Porque ProductList hace un early return: cuando props.products.length === 0, devuelve <p class="empty-state">...</p> y nunca llega al return que arma la <section> con el .map(). La rama de la lista no se ejecuta, así que no hay <section> en el HTML.
  • (c) La key está en cada <ProductCard key={product.id} ... />, dentro del .map() de ProductList —en el elemento que el .map() devuelve directamente—. No aparece en el HTML porque la key es de uso interno de React (le sirve para reconciliar entre renders, lección 6); no es un atributo HTML, así que no se emite al DOM. En nuestro renderToString de Node ni la pasamos, por eso tampoco sale; en React real estaría en el JSX pero igual invisible en el HTML.

Resumen y siguiente paso

En este mini-proyecto ensamblaste el catálogo del storefront de Mercado con JSX real, integrando todo el módulo en un solo entregable: el SearchBar con sus atributos (htmlFor, className, value como expresión), el ProductCard con su ternario y su badge condicional (&&), el ProductList con su .map(), su key={product.id} y su early return de "sin resultados", y el StorefrontCatalog que los compone bajando datos por props. Lo ejecutaste en Node y viste el HTML completo salir dos veces —con productos (tres tarjetas, badge en el agotado) y con la lista vacía (el estado "sin resultados")—, localizando cada herramienta del módulo en la salida. Todo descrito, no manipulado; todo verificado, no citado de memoria.

Con esto cierras el módulo 2. Sabes leer y escribir JSX entendiéndolo como JavaScript, poner los atributos correctos, condicionar y listar la UI, elegir una key estable, y sostener la postura declarativa. Tienes la foto fija del storefront, completa y correcta.

Lo que sigue le da vida. El módulo 3 introduce el estado con useState: hasta ahora, cuando la UI "cambiaba", éramos nosotros llamando a la función con props distintas; el estado hará que ese cambio ocurra dentro del componente y dispare el re-render solo. El módulo 4 trae los eventos: el <input> del SearchBar responderá al tecleo, y el botón "Add to cart" al clic. Y el módulo 5 cerrará el círculo del catálogo: la lista de productos se derivará de la búsqueda (query) filtrando en vivo —ese products: [] que hoy te dimos a mano será el resultado de filtrar por una búsqueda que no encontró nada—. La foto fija que montaste hoy está a punto de convertirse en una interfaz viva.

Recursos

  • React, "Describing the UI" — react.dev/learn/describing-the-ui. El repaso completo de todo lo que integraste aquí: componentes, JSX, props, condicional y listas. En inglés.
  • React, "Thinking in React" — react.dev/learn/thinking-in-react. Cómo pasar de una pantalla a un árbol de componentes que la describe; el método detrás de este catálogo. En inglés.
  • React, "Rendering Lists" — react.dev/learn/rendering-lists. Repaso de .map() y la key del ProductList. En inglés.
  • React, "Adding Interactivity" — react.dev/learn/adding-interactivity. El índice de la siguiente sección: estado y eventos, que darán vida al catálogo en los módulos 3 y 4. En inglés.