Props: datos desde arriba
En resumen: Los props son los datos que un componente recibe de su padre, igual que una función recibe parámetros; el hijo no los cambia, y los slots traen contenido desde fuera.
Problema
Tres veces pan
El catálogo tiene que mostrar tres productos: pan, leche y queso. La tarjeta de producto está lista de la lección anterior, así que App la pone tres veces.
Consola
Las tres tarjetas son pan. El producto está escrito dentro de ProductCard, y todas las tarjetas lo muestran. El otro camino sería BreadCard, MilkCard y CheeseCard: tres copias del mismo componente, y cada cambio en la tarjeta habría que hacerlo tres veces. Lo que falta es una forma de decirle a la tarjeta qué producto mostrar.
La idea
Props: datos desde arriba
Un componente es una función. Una función recibe sus datos como parámetros, y un componente los recibe como atributos de su padre: el componente que lo usa. Estos atributos se llaman props (de «properties»). El padre los escribe, el hijo los lee.
<ProductCard :product="bread" /><ProductCard :product="milk" /><script setup>defineProps({ product: Object });</script>
<template> <h2>{{ product.name }}</h2></template>defineProps dice qué props acepta el componente. La plantilla los ve directamente, por su nombre. En el script están en el objeto que devuelve defineProps: const props = defineProps(…), y luego props.product.name. Los dos puntos de :product son los mismos que en cualquier atributo: el valor es una expresión, no texto.
Hay una sola tarjeta, y cada uso tiene datos distintos. Con una función pasa lo mismo: una definición, muchas llamadas con argumentos distintos.
Míralo
Mira cómo fluyen
<script setup>defineProps({ product: Object, discount: { type: Number, default: 0 },});</script>
<template> <article> <h2>{{ product.name }}</h2> <p>Precio: {{ money(product.price * (1 - discount / 100)) }}</p> </article></template>
<!-- App.vue --><script setup>const bread = { name: 'Pan blanco', price: 1.2 };const milk = { name: 'Leche fresca', price: 1.6 };const cheese = { name: 'Queso blanco', price: 7.9 };</script>
<template> <main> <h1>Pan y leche</h1> <div> <ProductCard :product="bread" /> <ProductCard :product="milk" /> <ProductCard :product="cheese" :discount="10" /> </div> </main></template>Paso a paso, en palabras
- App guarda los datos: tres productos. Debajo hay tres ProductCard: el mismo componente tres veces, y ninguna sabe aún qué mostrar.
- :product="bread" le pasa el objeto bread a la primera tarjeta. Los atributos de un componente se llaman props.
- Las otras dos tarjetas reciben su propio producto. La tercera recibe además discount = 10. Cada tarjeta tiene sus propios props.
- Cada tarjeta se dibuja a partir de sus props. El pan y la leche no recibieron discount, por eso vale 0 por defecto. El queso lleva un 10 % de descuento: 7,11 €.
- Si la tarjeta intenta props.discount = 0, no sube nada: Vue avisa «target is readonly» y no hace el cambio. Los datos de App siguen igual.
- Los datos fluyen en una sola dirección: del padre hacia abajo, al hijo. Para cambiar algo arriba, el hijo tiene que pedírselo al padre. Ese es el módulo «Estado».
Cada tarjeta conoce solo sus propios props. No ve las otras tarjetas, y no sabe de dónde salen los datos de App.
Míralo
Una tarjeta, tres productos
Consola
Los productos están en App, y hay una sola ProductCard. Añade un cuarto producto en App (la imagen img/apples.svg existe) y un <ProductCard … /> más. La tarjeta no la tocas. Con 30 productos querrás una lista en lugar de 30 líneas: es la próxima lección.
Pruébalo
Encuentra el error
La tarjeta no se muestra, y en la consola dice «TypeError: Cannot read properties of undefined (reading 'toFixed')».
Elige la línea con el error.
product="bread" sin los dos puntos pasa el texto «bread», no el objeto. El texto no tiene price, así que product.price es undefined y money falla en toFixed. El objeto se pasa con los dos puntos: :product="bread". Si la tarjeta hubiera declarado product: Object, Vue avisa incluso antes: «Invalid prop: type check failed for prop "product". Expected Object, got String».La idea
Un valor por defecto
Algunos productos están en oferta. La tarjeta recibe un segundo prop, discount, en porcentaje. La mayoría de los productos no tienen descuento, y no queda bien que cada línea de App escriba discount={0}. Un prop que falta es undefined, igual que un argumento que falta en una función. El precio se convierte en price * (1 - undefined / 100), o sea NaN.
Por eso el prop recibe un valor por defecto:
defineProps({ product: Object, discount: { type: Number, default: 0 },});default es el valor cuando el padre no pasa nada. type es una comprobación: si pasas discount="10" (texto, sin los dos puntos), Vue avisa en la consola «Invalid prop: type check failed». La comprobación solo se ejecuta en modo de desarrollo, y solo avisa.
Pruébalo
Un descuento
App le pasa al queso un discount de 10 (por ciento). Haz que la tarjeta acepte discount, 0 por defecto, y que muestre el precio con el descuento: el pan 1,20 €, la leche 1,60 €, el queso 7,11 €.
Consola
Solución
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Precio: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const bread = { name: 'Pan blanco', price: 1.2 };
const milk = { name: 'Leche fresca', price: 1.6 };
const cheese = { name: 'Queso blanco', price: 7.9 };
</script>
<template>
<main>
<h1>Pan y leche</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
discount: { type: Number, default: 0 } en defineProps y el precio money(product.price * (1 - discount / 100)). Con discount: Number y sin default, el pan y la leche muestran «NaN €»: la comprobación mira los tres precios.
La idea
Solo lectura
Los datos son de quien los tiene. La tarjeta puede leer sus props, pero no puede cambiarlos. Prueba el botón «Uno más»: el hijo intenta aumentar su propio qty.
Consola
La cantidad se queda en 2, y la consola dice «[Vue warn] Set operation on key "qty" failed: target is readonly.». Vue le da al componente props de solo lectura y avisa ante un intento de escritura.
Aunque pasara, el cambio sería solo en el hijo: App seguiría diciendo 2, y en el siguiente dibujo App volvería a pasar 2. Dos lugares con verdades distintas son el error de «Una lista a partir de datos». La regla: los datos fluyen hacia abajo, y cambiarlos es trabajo del padre. Cómo le pide un hijo a su padre que cambie algo es el módulo «Estado».
La idea
Contenido desde fuera
La tienda tiene marcos que se ven igual y llevan cosas distintas dentro: «Envío» con dos párrafos, «Devoluciones» con uno. El título es un prop. El contenido es un trozo entero de HTML, y es más cómodo escribirlo entre la etiqueta de apertura y la de cierre, como con <section>.
<Card title="Envío"> <p>En un máximo de 2 días laborables.</p></Card><template> <section class="card"> <h2>{{ title }}</h2> <slot /> </section></template>El contenido entre <Card> y </Card> va donde está <slot />. Card decide dónde está el slot.
Consola
Pruébalo
El marco
Card muestra solo el título. Haz que muestre también, bajo el título, el contenido que App escribe entre <Card> y </Card>.
Consola
Solución
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
App.vue<script setup>
import Card from './Card.vue';
</script>
<template>
<main>
<h1>El carrito</h1>
<Card title="Envío">
<p>En un máximo de 2 días laborables.</p>
<p>Gratis a partir de 30 €.</p>
</Card>
<Card title="Devoluciones">
<p>Hasta 14 días desde la entrega.</p>
</Card>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
<slot /> bajo el título. Un slot con nombre (<slot name="content" />) no funciona: App no da ningún nombre, así que el contenido va al slot por defecto, y no hay ninguno. Si pones el slot dentro de <h2>, los párrafos pasan a ser parte del título, y ya no es «Envío».
Más precisamente, para curiosos
Slots con nombre. Un marco puede tener varios lugares: <slot /> para la parte principal y <slot name="footer" /> para la de abajo. El padre rellena un slot con nombre con <template #footer>…</template>.
Nombres. En el script un prop va en camelCase (productId), y en la plantilla del padre también puede ir en kebab-case (:product-id="7"). Vue los conecta.
Props como variables. Desde Vue 3.5 puedes escribir const { product, discount = 0 } = defineProps(['product', 'discount']): el valor por defecto funciona como en JavaScript, y las variables siguen conectadas con el padre.
Solo el primer nivel está congelado. props.qty = 3 avisa, pero props.product.price = 2 pasa en silencio: Vue solo protege de la escritura el primer nivel, y el objeto del producto es el mismo que en App. Así el hijo cambia los datos del padre a sus espaldas. No lo hagas.
Tipos. Además de type y default hay required: true (un aviso si el padre no pasa el prop) y validator (una función que comprueba el valor). Todo se ejecuta solo en modo de desarrollo. Con TypeScript los tipos se escriben en defineProps<{ … }>().
Ejercicio
El escaparate
Construye el escaparate desde cero. ProductCard recibe product y discount (0 por defecto) y muestra la imagen (alt es el nombre), el nombre como título de nivel dos y «Precio: …» con el descuento. Card recibe title y muestra el título (nivel dos) y el contenido desde fuera. En App, bajo el título: las tres tarjetas en un div (el queso con discount 10), después una Card «Envío» con «En un máximo de 2 días laborables.» y «Gratis a partir de 30 €.».
Consola
Solución
App.vue<script setup>
import ProductCard from './ProductCard.vue';
import Card from './Card.vue';
const bread = { name: 'Pan blanco', price: 1.2, image: 'img/bread.svg' };
const milk = { name: 'Leche fresca', price: 1.6, image: 'img/milk.svg' };
const cheese = { name: 'Queso blanco', price: 7.9, image: 'img/cheese.svg' };
</script>
<template>
<main>
<h1>El escaparate</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
<Card title="Envío">
<p>En un máximo de 2 días laborables.</p>
<p>Gratis a partir de 30 €.</p>
</Card>
</main>
</template>
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<img :src="product.image" :alt="product.name" />
<h2>{{ product.name }}</h2>
<p>Precio: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
ProductCard: defineProps({ product: Object, discount: { type: Number, default: 0 } }), <img :src="product.image" :alt="product.name" />, <h2>{{ product.name }}</h2> y el precio con el descuento. Card: defineProps({ title: String }), <section class="card">, <h2>{{ title }}</h2> y <slot />. App: <ProductCard :product="cheese" :discount="10" /> y el contenido entre <Card title="Envío"> y </Card>. La comprobación encuentra las imágenes por alt, los precios de las tres tarjetas y los párrafos dentro de .card.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Texto, enlaces, imágenes, tablas · Accesibilidad · Una lista a partir de datos · El primer componente
- Continúa en: Listas y claves · Condiciones: vacío, error, cargando · Estado · Estado derivado
- Ejercicios sobre el tema: Componentes