El primer componente
En resumen: Un componente de React es una función que devuelve JSX: describe cómo se ve la pantalla según los datos, y React cambia en la página solo la diferencia.
Problema
El campo pierde el foco
El módulo «JavaScript en la página» terminó con un carrito dibujado a partir de los datos por un único render(). Aquí cada cambio del campo de cantidad llama a render(). El cliente quiere 15 botellas de leche: hace clic en el campo, borra el 2 y escribe 15. Pruébalo.
Consola
Tras borrar, el campo está vacío y ya no tiene el foco: el 1 y el 5 no van a ninguna parte. render() sustituyó el campo por uno nuevo con la primera tecla. El campo nuevo se ve igual, pero es otro elemento, y el foco estaba en el viejo. Tocar a mano cada lugar por separado hace que se olviden lugares. Dibujarlo todo de nuevo hace perder el foco. Hay un tercer camino.
La idea
La interfaz como función de los datos
render() tenía una buena idea: la pantalla se construye solo a partir de los datos. Con los mismos datos la pantalla es la misma, sea lo que sea lo que hubiera antes. El error estaba en el último paso: render() quita y crea los elementos él mismo.
Por eso el trabajo se divide en dos:
- Tú escribes una función que dice cómo se ve la pantalla para esos datos. No toca la página; devuelve una descripción: aquí hay un
<main>, dentro un<h1>con el texto «El carrito», luego un campo con el valor 2. - La biblioteca guarda el DOM real. La primera vez lo construye a partir de la descripción. En cada cambio de los datos toma la descripción nueva, la compara con la anterior y cambia en la página solo la diferencia.
Una función así se llama componente. En corto: la pantalla es una función de los datos, y el componente es esa función. React y Vue lo hacen de formas distintas. La idea es la misma, y elegir entre ellos es cosa del equipo, no de una biblioteca «mejor».
Míralo
El primer componente
En React un componente es una función que devuelve JSX: una notación que parece HTML pero vive dentro de JavaScript.
const product = { name: 'Leche fresca', price: 1.6, stock: 35 };
export default function App() { return ( <main> <h1>{product.name}</h1> <p>Precio: {money(product.price)}</p> </main> );}function App()es una función normal. El nombre empieza con mayúscula: así distingue React un componente de una etiqueta HTML.return ( … )devuelve la descripción. Los paréntesis solo sirven para que JSX pueda empezar en una línea nueva.{product.name}es una expresión de JavaScript entre llaves. Su resultado se convierte en texto en la página.export defaultpermite que otro archivo importe el componente. El sandbox poneAppen la página por sí solo. En un proyecto real lo hace una línea enmain.jsx:createRoot(...).render(<App />).
Consola
Cambia el precio o el stock en el objeto product y pulsa «Ejecutar». No tocas la descripción de la pantalla: la pantalla sigue a los datos.
La idea
Las reglas de JSX
JSX parece HTML, pero no es HTML: antes de ejecutarse, cada etiqueta se convierte en una llamada a una función de JavaScript. Por eso tiene reglas propias.
- Una sola raíz.
returndevuelve un elemento. Si varios tienen que ir juntos, envuélvelos en un fragmento<>…</>: no crea ningún elemento en la página. classNameen lugar declass,htmlForen lugar defor.classyforson palabras de JavaScript. Conclassla página sigue funcionando, pero React escribe un error en la consola: «Invalid DOM propertyclass. Did you meanclassName?».{expresión}en el texto y en los atributos:<img src={product.image} alt={product.name} />. Sin comillas alrededor de las llaves:alt="{product.name}"es el texto{product.name}, letra por letra.- Todas las etiquetas se cierran, también las que no tienen contenido:
<input />,<img />,<br />. - Los eventos se escriben en camelCase y reciben una función:
onClick={add}. NoonClick={add()}: eso llama aaddenseguida, mientras se dibuja el componente, y le pasa aonClickel resultado.
Pruébalo
Encuentra el error
Un clic en «Añadir» no escribe nada en la consola.
Elige la línea con el error.
onclick="add()" es notación de HTML. React solo acepta los eventos como onClick con una función: onClick={add}. Se salta este atributo y escribe un error en la consola: «Invalid event handler property onclick. Did you mean onClick?».Pruébalo
Completa
El lector de pantalla tiene que leer el nombre del producto como descripción de la imagen.
<img src={product.image} alt= />alt={product.name}: la expresión va entre llaves, sin comillas.La idea
Componentes hechos de componentes
La página de la tienda tiene partes: la parte de arriba con el menú, la tarjeta del producto, el carrito. Cada parte se convierte en un componente en su propio archivo, y un componente mayor los usa como etiquetas. Así App no es una función larga, sino unas pocas líneas que dicen qué hay en la página.
export default function ShopHeader() { return ( <header> <a href="/">La tienda</a> <nav>…</nav> </header> );}import ShopHeader from './ShopHeader';import ProductCard from './ProductCard';
export default function App() { return ( <> <ShopHeader /> <main> <ProductCard /> </main> </> );}El nombre del componente empieza con mayúscula, y no es cuestión de gusto. Para React <ShopHeader /> es un componente, y <shopHeader /> es una etiqueta HTML <shopheader> que el navegador no conoce. Entonces hay un error en la consola y falta la parte de arriba. El fragmento <>…</> está porque ShopHeader y <main> van uno junto al otro.
Pruébalo
Monta la página
ShopHeader y ProductCard están listos en sus archivos. Monta App: ShopHeader arriba, y la tarjeta del producto en main, bajo el título «Lácteos».
Consola
Solución
App.jsximport ShopHeader from './ShopHeader';
import ProductCard from './ProductCard';
export default function App() {
return (
<>
<ShopHeader />
<main>
<h1>Lácteos</h1>
<ProductCard />
</main>
</>
);
}
ShopHeader.jsxexport default function ShopHeader() {
return (
<header>
<a href="/">La tienda</a>
<nav>
<a href="/bread">Pan</a>
<a href="/milk">Leche</a>
<a href="/cart">Carrito</a>
</nav>
</header>
);
}
ProductCard.jsxconst product = { name: 'Leche fresca', price: 1.6, stock: 35 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard() {
return (
<article>
<h2>{product.name}</h2>
<p>Precio: {money(product.price)}</p>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
App importa los dos componentes con import y los usa como etiquetas: <ShopHeader /> antes de <main>, <ProductCard /> dentro de <main>. Van uno junto al otro, por eso están en un fragmento <>…</>. La comprobación busca la parte de arriba por su rol: un <header> dentro de <main> ya no es la cabecera de la página, y un <shopHeader /> en minúscula no tiene ningún <header>.
La idea
Solo la diferencia
Aquí está el carrito del problema como componente.
useState('2') es la memoria del componente: un valor que React conserva entre dos dibujos, y una función (setMilk) que lo cambia y le dice a React que dibuje el componente otra vez. Es el tema del módulo «Estado». Aquí solo está para que algo pueda cambiar.
Consola
Haz lo mismo que en el problema: clic en el campo de leche, borra el 2 y escribe 15. El campo conserva el foco y lo que escribiste, y el total pasa a 25,20 €. El componente describe toda la pantalla con cada tecla, igual que render(). La diferencia es que la página no se construye de nuevo. La biblioteca ve que en la descripción nueva el campo está en el mismo lugar con la misma etiqueta, y cambia solo su valor y el texto del total.
Míralo
Mira la comparación
import { useState } from 'react';const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function App() { const [milk, setMilk] = useState('2'); const [bread, setBread] = useState('1'); const total = Number(milk) * 1.6 + Number(bread) * 1.2;
return ( <main> <h1>El carrito</h1> <label> Leche fresca <input type="number" value={milk} onChange={(e) => setMilk(e.target.value)} /> </label> <label> Pan blanco <input type="number" value={bread} onChange={(e) => setBread(e.target.value)} /> </label> <p>Total: {money(total)}</p> </main> );}Paso a paso, en palabras
- El cliente ha borrado el 2 y ha escrito 1: los datos dicen milk = "1". En el centro está la última descripción que devolvió el componente; a la derecha está el DOM, con el foco en el campo de leche.
- El cliente escribe 5. onChange llama a setMilk("15"): los datos cambian, y React anota que hay que volver a dibujar App.
- React vuelve a llamar a la función App. No toca la página: devuelve una descripción nueva, un árbol de objetos simples. Construirla es barato.
- La biblioteca compara la descripción nueva con la anterior. Las mismas etiquetas en el mismo orden y dos diferencias: el valor del campo y el texto del total.
- Solo las dos diferencias llegan al DOM. El <input> es el mismo elemento de antes, por eso el foco y el cursor se quedan en él.
- Tú describes toda la pantalla para los datos dados, como con render(). La biblioteca encuentra qué cambió y toca solo eso. Las dos bibliotecas lo hacen, cada una a su manera.
La descripción es barata: es un árbol de objetos simples, y el componente lo rehace cada vez. El DOM es la parte cara, así que la biblioteca solo toca ahí las diferencias. Tú escribes como con render(): toda la pantalla a partir de los datos. El precio de render() (el foco perdido, miles de elementos nuevos) no se paga.
Más precisamente, para curiosos
DOM virtual. A la descripción se la suele llamar DOM virtual, y a su comparación con la anterior, reconciliación (React) o patching (Vue). Un nodo de la descripción es un objeto como { type: 'h1', props: { children: 'El carrito' } }.
Los dos caminos. JSX se compila a llamadas como jsx('h1', { children: product.name }). Cuando cambia la memoria de un componente, React vuelve a llamar a su función, y a las de los componentes que contiene, y luego compara. En los dos casos al final hay una descripción nueva, una comparación y unos pocos cambios en el DOM.
El texto es texto. {product.name} inserta texto, como textContent. Un nombre como <img src=x onerror=alert(1)> se muestra como caracteres y no se convierte en una etiqueta. El HTML de una cadena solo entra con dangerouslySetInnerHTML, y el nombre ya avisa de que hay que tener cuidado.
Componentes de clase. En código antiguo un componente es una clase: class Cart extends React.Component { render() { … } }. Sigue funcionando, pero el código nuevo usa funciones y hooks (useState es un hook).
StrictMode. Un proyecto nuevo de Vite envuelve App en <StrictMode>. En modo de desarrollo llama a cada componente dos veces, para sacar a la luz el código que toca algo fuera de sí mismo. Por eso un console.log en el cuerpo de un componente puede aparecer dos veces. El sandbox dibuja App sin StrictMode.
Ejercicio
La tarjeta del producto
En ProductCard está el objeto product (Queso blanco). Haz la tarjeta: el nombre como título de nivel dos, «Precio: 7,90 €» (con money), «En stock: 12 uds.» y un botón «Añadir» cuyo nombre para el lector de pantalla sea «Añadir Queso blanco». Un clic en el botón escribe «Añadido: Queso blanco» en la consola (la función add ya está hecha). Después monta App: ShopHeader arriba, y la tarjeta en main, bajo el título «Lácteos».
Consola
Solución
ProductCard.jsxconst product = { name: 'Queso blanco', price: 7.9, stock: 12 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard() {
function add() {
console.log(`Añadido: ${product.name}`);
}
return (
<article>
<h2>{product.name}</h2>
<p>Precio: {money(product.price)}</p>
<p>En stock: {product.stock} uds.</p>
<button type="button" aria-label={`Añadir ${product.name}`} onClick={add}>
Añadir
</button>
</article>
);
}
App.jsximport ShopHeader from './ShopHeader';
import ProductCard from './ProductCard';
export default function App() {
return (
<>
<ShopHeader />
<main>
<h1>Lácteos</h1>
<ProductCard />
</main>
</>
);
}
ShopHeader.jsxexport default function ShopHeader() {
return (
<header>
<a href="/">La tienda</a>
<nav>
<a href="/bread">Pan</a>
<a href="/milk">Leche</a>
<a href="/cart">Carrito</a>
</nav>
</header>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
Todo lo que viene de los datos va en {…}: {product.name}, {money(product.price)}, {product.stock}. El nombre para el lector de pantalla es una expresión: aria-label={`Añadir ${product.name}`}. El botón recibe la función: onClick={add}. Con onClick={add()} «Añadido: Queso blanco» aparece en la consola ya al cargar, sin clic, y la comprobación busca justo eso.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Accesibilidad · El DOM: la página desde JavaScript · Eventos · Una lista a partir de datos
- Continúa en: Props: datos desde arriba · Listas y claves · Condiciones: vacío, error, cargando · Estado
- Ejercicios sobre el tema: Componentes