Saltar al contenido
schedule 12 min Vue

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:

MiComponente.vue
<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:

src/components/CrewCard.vue
<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:

src/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.

src/components/CrewCard.vue
<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:

src/App.vue
<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:

CrewCard.vue (alternativa)
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.

src/components/CrewCard.vue
<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' => ' ' ])

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:

src/components/CrewList.vue
<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:

src/App.vue
<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.

src/components/InfoPanel.vue
<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:

App.vue (fragmento)
<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.

Ejemplo: con y sin scoped
<!-- 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.

code

Construye un roster de equipo galáctico

Medio schedule 25 min

Crea una app de roster de equipo con los siguientes componentes:

  1. Crea un componente MemberCard.vue en src/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)
  2. El componente debe emitir un evento select cuando se haga clic en la tarjeta, enviando un objeto con name y role.
  3. En App.vue, crea un array reactivo con al menos 4 miembros del equipo y renderiza un MemberCard por cada uno usando v-for.
  4. Cuando el usuario haga clic en una tarjeta, muestra los detalles del miembro seleccionado debajo de la lista (nombre y rol en grande).
  5. Añade un <style scoped> tanto en MemberCard.vue como en App.vue para 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}.

Newsletter

Recibe nuevos cursos, actualizaciones, artículos del blog y promociones en tu correo.