Componentes y props
Hasta ahora has metido todo tu código en App.vue. Funciona, pero las apps reales tienen decenas o cientos de componentes. Los componentes son los bloques de construcción de Vue: piezas reutilizables de interfaz que compones como si fueran piezas de LEGO. Cada componente encapsula su propio HTML, lógica y estilos. Dominar componentes y props es lo que separa "sé un poco de Vue" de "puedo construir apps de verdad".
¿Qué es un componente?
Un componente es un archivo .vue con su propia plantilla, lógica y estilos. Es una unidad independiente y reutilizable. Piensa en un componente como una carta de un juego de cartas: tiene su diseño, sus datos (nombre, estadísticas, imagen) y su comportamiento (al hacer clic se voltea). Puedes tener 50 cartas diferentes, pero todas siguen la misma estructura.
Ya conoces la estructura SFC (Single File Component) de la lección anterior:
<script setup>
// Lógica del componente
</script>
<template>
<!-- HTML del componente -->
</template>
<style scoped>
/* Estilos del componente */
</style>
Creando tu primer componente
Vamos a construir algo divertido: una app de tripulación espacial. Cada miembro de la tripulación será un componente. Primero, crea el archivo del componente:
<script setup>
// Por ahora, sin lógica
</script>
<template>
<div class="crew-card">
<img src="https://api.dicebear.com/9.x/bottts/svg?seed=Nova" alt="Avatar" class="crew-card__avatar" />
<h3 class="crew-card__name">Nova</h3>
<p class="crew-card__role">Piloto</p>
</div>
</template>
<style scoped>
.crew-card {
background: #1a1a2e;
border: 2px solid #4FC08D;
border-radius: 12px;
padding: 1.5rem;
text-align: center;
color: #e0e0e0;
transition: transform 0.2s;
}
.crew-card:hover {
transform: translateY(-4px);
}
.crew-card__avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 0.75rem;
}
.crew-card__name {
margin: 0;
font-size: 1.25rem;
color: #4FC08D;
}
.crew-card__role {
margin: 0.25rem 0 0;
font-size: 0.9rem;
opacity: 0.8;
}
</style>
Ahora impórtalo y úsalo en App.vue:
<script setup>
import CrewCard from "./components/CrewCard.vue";
</script>
<template>
<div class="app">
<h1>Tripulación Estelar</h1>
<CrewCard />
<CrewCard />
<CrewCard />
</div>
</template>
Con <script setup>, los componentes importados están disponibles directamente en el template sin necesidad de registrarlos. Pero hay un problema evidente: las tres tarjetas muestran la misma persona. Necesitamos una forma de pasar datos diferentes a cada una. Para eso existen las props.
Props con defineProps()
Las props son el mecanismo para pasar datos de un componente padre a un componente hijo. Es como un formulario: el padre rellena los campos y el hijo los muestra.
<script setup>
const props = defineProps({
name: {
type: String,
required: true,
},
role: {
type: String,
required: true,
},
avatar: {
type: String,
default: "https://api.dicebear.com/9.x/bottts/svg?seed=default",
},
level: {
type: Number,
default: 1,
},
});
</script>
<template>
<div class="crew-card">
<img :src="avatar" :alt="name" class="crew-card__avatar" />
<h3 class="crew-card__name">{{ name }}</h3>
<p class="crew-card__role">{{ role }}</p>
<p class="crew-card__level">Nivel {{ level }}</p>
</div>
</template>
Ahora el padre puede pasar datos distintos a cada tarjeta:
<script setup>
import CrewCard from "./components/CrewCard.vue";
</script>
<template>
<div class="app">
<h1>Tripulación Estelar</h1>
<div class="crew-grid">
<CrewCard
name="Nova"
role="Piloto"
avatar="https://api.dicebear.com/9.x/bottts/svg?seed=Nova"
:level="5"
/>
<CrewCard
name="Orion"
role="Ingeniera de sistemas"
avatar="https://api.dicebear.com/9.x/bottts/svg?seed=Orion"
:level="3"
/>
<CrewCard
name="Vega"
role="Científica"
avatar="https://api.dicebear.com/9.x/bottts/svg?seed=Vega"
:level="4"
/>
</div>
</div>
</template>
Fíjate en un detalle importante: name y role se pasan como strings (sin :), pero :level usa el prefijo : (shorthand de v-bind) porque necesitamos pasar un número, no el texto "5".
Valores por defecto con withDefaults
Si prefieres el estilo con tipos de TypeScript (funciona también sin TypeScript en Vue 3.5+), puedes usar withDefaults:
const props = withDefaults(
defineProps<{
name: string;
role: string;
avatar?: string;
level?: number;
}>(),
{
avatar: "https://api.dicebear.com/9.x/bottts/svg?seed=default",
level: 1,
}
);
Ambas formas son válidas. La primera (objeto de opciones) es más explícita; la segunda (genéricos) es más concisa si ya conoces TypeScript.
Las props son de solo lectura
Regla fundamental: nunca mutes una prop directamente. Las props vienen del padre y solo el padre puede cambiarlas. Si intentas modificar una prop, Vue te lo advertirá en la consola:
// MAL - Vue te dará un warning
props.level = 10;
// BIEN - si necesitas un valor derivado, usa una variable local o computed
const displayLevel = computed(() => `Nv. ${props.level}`);
Este principio se llama flujo de datos unidireccional: los datos bajan del padre al hijo por props, nunca al revés. Pero entonces... ¿cómo le dice el hijo al padre que algo pasó? Con eventos.
Emitiendo eventos con defineEmits()
Si las props son la forma de pasar datos hacia abajo (padre a hijo), los eventos son la forma de comunicar hacia arriba (hijo a padre). Es como un walkie-talkie: el hijo emite una señal y el padre la escucha.
<script setup>
const props = defineProps({
name: {
type: String,
required: true,
},
role: {
type: String,
required: true,
},
avatar: {
type: String,
default: "https://api.dicebear.com/9.x/bottts/svg?seed=default",
},
level: {
type: Number,
default: 1,
},
});
// Declarar los eventos que este componente puede emitir
const emit = defineEmits(["select", "promote"]);
const handleSelect = () => {
// Emitir el evento con datos
emit("select", { name: props.name, role: props.role });
};
const handlePromote = () => {
emit("promote", props.name);
};
</script>
<template>
<div class="crew-card" @click="handleSelect">
<img :src="avatar" :alt="name" class="crew-card__avatar" />
<h3 class="crew-card__name">{{ name }}</h3>
<p class="crew-card__role">{{ role }}</p>
<p class="crew-card__level">Nivel {{ level }}</p>
<button class="crew-card__btn" @click.stop="handlePromote">
Ascender
</button>
</div>
</template>
Fíjate en el @click.stop del botón: el modificador .stop evita que el clic del botón también dispare el @click de la tarjeta. Sin él, al hacer clic en "Ascender" se emitirían ambos eventos.
Ahora el padre escucha los eventos:
@include('front.web-course.components.code-block', [ 'language' => 'markup', 'filename' => 'src/App.vue', 'code' => 'Tripulación Estelar
Miembro seleccionado
{{ selectedMember.name }} — {{ selectedMember.role }}
Observa el patrón completo: props bajan, eventos suben. El padre pasa :name, :role, :level al hijo, y el hijo emite @select y @promote al padre. Este es el flujo fundamental de datos en Vue.
Composición de componentes
Las apps reales tienen componentes dentro de componentes dentro de componentes. Cada uno se encarga de una cosa concreta. Veamos cómo estructurar nuestra app de tripulación con un componente intermedio:
<script setup>
import CrewCard from "./CrewCard.vue";
const props = defineProps({
members: {
type: Array,
required: true,
},
});
const emit = defineEmits(["select", "promote"]);
</script>
<template>
<div class="crew-list">
<h2 class="crew-list__title">
Tripulación ({{ members.length }} miembros)
</h2>
<div class="crew-list__grid">
<CrewCard
v-for="member in members"
:key="member.name"
:name="member.name"
:role="member.role"
:avatar="member.avatar"
:level="member.level"
@select="(data) => emit('select', data)"
@promote="(name) => emit('promote', name)"
/>
</div>
</div>
</template>
<style scoped>
.crew-list__title {
color: #4FC08D;
margin-bottom: 1rem;
}
.crew-list__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
</style>
Y App.vue se simplifica:
<script setup>
import { ref } from "vue";
import CrewList from "./components/CrewList.vue";
const crew = ref([
{ name: "Nova", role: "Piloto", avatar: "https://api.dicebear.com/9.x/bottts/svg?seed=Nova", level: 5 },
{ name: "Orion", role: "Ingeniera de sistemas", avatar: "https://api.dicebear.com/9.x/bottts/svg?seed=Orion", level: 3 },
{ name: "Vega", role: "Científica", avatar: "https://api.dicebear.com/9.x/bottts/svg?seed=Vega", level: 4 },
]);
const selectedMember = ref(null);
const onSelect = (member) => {
selectedMember.value = member;
};
const onPromote = (memberName) => {
const member = crew.value.find((m) => m.name === memberName);
if (member) {
member.level++;
}
};
</script>
<template>
<div class="app">
<h1>Tripulación Estelar</h1>
<CrewList
:members="crew"
@select="onSelect"
@promote="onPromote"
/>
<div v-if="selectedMember" class="selected-info">
<p>Seleccionado: <strong>{{ selectedMember.name }}</strong> — {{ selectedMember.role }}</p>
</div>
</div>
</template>
La jerarquía queda así:
@include('front.web-course.components.code-block', [ 'language' => 'markup', 'code' => 'App.vue └── CrewList.vue ← recibe :members, emite @select y @promote └── CrewCard.vue ← recibe :name, :role, etc., emite @select y @promote' ])Cada componente tiene una responsabilidad clara: App.vue gestiona el estado, CrewList organiza la lista y CrewCard muestra una tarjeta individual.
Slots: insertar contenido desde el padre
A veces quieres que un componente sea un "contenedor" y que el padre decida qué va dentro. Para eso existen los slots. Es como una caja con un hueco: el componente define la caja y el padre elige qué meter en el hueco.
<script setup>
defineProps({
title: {
type: String,
required: true,
},
});
</script>
<template>
<div class="info-panel">
<h3 class="info-panel__title">{{ title }}</h3>
<div class="info-panel__body">
<slot />
</div>
</div>
</template>
<style scoped>
.info-panel {
background: #16213e;
border-left: 4px solid #4FC08D;
border-radius: 8px;
padding: 1rem 1.5rem;
margin: 1rem 0;
}
.info-panel__title {
color: #4FC08D;
margin: 0 0 0.5rem;
}
</style>
Y el padre decide qué contenido va dentro:
<InfoPanel title="Estado de la misión">
<p>Día 47 en el espacio profundo.</p>
<p>Combustible restante: <strong>62%</strong></p>
<p>Próximo destino: Kepler-442b</p>
</InfoPanel>
<InfoPanel title="Alertas">
<ul>
<li>Sensor de oxígeno requiere calibración</li>
<li>Actualización de firmware disponible</li>
</ul>
</InfoPanel>
El <slot /> se reemplaza con lo que el padre ponga entre las etiquetas del componente. Vue también soporta slots con nombre para insertar contenido en ubicaciones específicas, pero eso lo veremos en lecciones posteriores.
Estilos con scoped
¿Has notado que todos nuestros componentes usan <style scoped>? El atributo scoped hace que los estilos solo se apliquen a ese componente. Vue lo logra añadiendo un atributo único a los elementos del componente (algo como data-v-7ba5bd90) y usándolo en los selectores CSS.
<!-- CON scoped: los estilos SOLO afectan a este componente -->
<style scoped>
h3 {
color: #4FC08D; /* Solo los h3 de ESTE componente */
}
</style>
<!-- SIN scoped: los estilos son GLOBALES -->
<style>
h3 {
color: #4FC08D; /* TODOS los h3 de la app */
}
</style>
Usa scoped siempre por defecto. Solo omítelo cuando realmente necesites estilos globales (por ejemplo, para reset CSS o tipografía base). Esto evita conflictos de estilos entre componentes, que es uno de los problemas más frustrantes del CSS tradicional.
Resumen
| Concepto | Qué hace | Dirección |
|---|---|---|
defineProps() |
Recibir datos del padre | Padre → Hijo |
defineEmits() |
Emitir eventos al padre | Hijo → Padre |
<slot /> |
Insertar contenido desde el padre | Padre → Hijo |
scoped |
Aislar estilos al componente | — |
La regla de oro: props bajan, eventos suben. Si recuerdas esto, nunca te perderás en la comunicación entre componentes.
Construye un roster de equipo galáctico
Crea una app de roster de equipo con los siguientes componentes:
- Crea un componente
MemberCard.vueensrc/components/que reciba tres props:name(String, requerido)role(String, requerido) — por ejemplo: "Capitán", "Médica", "Hacker", "Mecánico"avatar(String, con valor por defecto usando DiceBear:https://api.dicebear.com/9.x/bottts/svg?seed=default)
- El componente debe emitir un evento
selectcuando se haga clic en la tarjeta, enviando un objeto connameyrole. - En
App.vue, crea un array reactivo con al menos 4 miembros del equipo y renderiza unMemberCardpor cada uno usandov-for. - Cuando el usuario haga clic en una tarjeta, muestra los detalles del miembro seleccionado debajo de la lista (nombre y rol en grande).
- Añade un
<style scoped>tanto enMemberCard.vuecomo enApp.vuepara que los estilos no interfieran entre sí.
Bonus: añade un botón "Limpiar selección" en App.vue que ponga selectedMember a null.
lightbulb Pistas
En MemberCard.vue, declara las props con defineProps({ name: { type: String, required: true }, ... }) y los eventos con const emit = defineEmits(["select"]). En el template, usa @click="emit('select', { name, role })". En App.vue, escucha el evento con @select="selectedMember = $event". Para el avatar, usa :src="avatar" con un default que use el nombre como seed: https://api.dicebear.com/9.x/bottts/svg?seed=${name}.