Skip to content
Vue Components Explained — Props, Slots, and Communication

Vue Components Explained — Props, Slots, and Communication

DodaTech Updated Jun 6, 2026 15 min read

Vue components are reusable building blocks that combine HTML, CSS, and JavaScript into independent pieces you can compose into complex interfaces.

What You’ll Learn

  • Create and register single-file components (SFCs)
  • Pass data from parent to child with props
  • Send events from child to parent with emits
  • Distribute content with slots and scoped slots
  • Switch between components dynamically with KeepAlive
  • Share data across deep component trees with provide/inject
  • Use scoped styles and template refs
  • Build a complete product card system with proper component communication

Why Components Matter

Without components, your entire app lives in one massive file — impossible to maintain, test, or reuse. Components let you split the UI into independent, reusable pieces like LEGO bricks. Each brick has its own template, logic, and styles.

At DodaTech, we use components extensively in Durga Antivirus Pro’s dashboard — the threat overview card, the scan progress bar, the quarantine list — each is a reusable component composed into dashboards. This also powers the file browser UI in DodaZIP, where folder views, file cards, and toolbar buttons are all independent components.

Your Learning Path

    flowchart LR
  A[Vue Basics] --> B[Vue Components]
  B --> C[Vue Router]
  C --> D[Vue Advanced]
  B:::current

  classDef current fill:#42b883,color:#fff,stroke:#3aa876,stroke-width:2px
  
Prerequisites: Complete https://tutorials.dodatech.com/frameworks/vue/vue-basics/ first. You need JavaScript (functions, arrays, objects) and HTML basics.

The LEGO Analogy — Why Components?

Think of a webpage as a LEGO castle. You don’t build it from individual studs — you build walls, towers, gates, and figures, then assemble them.

Components are those pre-built pieces:

  • Button component — used everywhere with consistent styling, just vary the label
  • Card component — displays products, users, or posts with different data
  • Layout component — provides the page structure (header, sidebar, content)

Each component is self-contained. It knows its own data, how to render, and how to communicate with others. This is called encapsulation — the component’s internal details are hidden from the outside.

Creating a Component — The .vue File

Every Vue component lives in a .vue file called a Single-File Component (SFC) with three sections. Let’s walk through each one:

<!-- /components/GreetingCard.vue -->

<!-- TEMPLATE: the HTML structure (with Vue enhancements).
     This is what gets rendered on the page. You can use all
     the Vue template syntax you learned in basics: {{ }},
     v-bind, v-if, v-for, etc. -->
<template>
  <div class="greeting">
    <h2>{{ title }}</h2>
    <p>{{ message }}</p>
  </div>
</template>

<!-- SCRIPT: the component's logic and data.
     This defines the component's behavior, state, and methods.
     export default makes this component importable by others. -->
<script>
export default {
  name: "GreetingCard",
  // data() returns reactive state — same as in a Vue app
  data() {
    return {
      title: "Hello",
      message: "Welcome to Vue components!"
    };
  }
};
</script>

<!-- STYLE: scoped to this component only.
     The "scoped" attribute tells Vue: "these styles should
     ONLY apply to elements in THIS component."
     Vue achieves this by adding a unique data attribute
     (like data-v-abc123) to every element and rewriting
     the CSS selectors accordingly. -->
<style scoped>
.greeting {
  border: 1px solid #42b883;
  padding: 16px;
  border-radius: 8px;
}
</style>

Why three sections? By keeping template, logic, and styles together, the component is a complete, portable unit. Move the .vue file to another project, and everything comes with it. No separate CSS or JS files to hunt down.

Registration — Two Ways

Local Registration (Recommended — Explicit Dependencies)

<script>
import GreetingCard from "./GreetingCard.vue";

export default {
  components: {
    GreetingCard    // Available only in THIS component
  }
};
</script>

<template>
  <!-- Use it like a normal HTML element (but self-closing) -->
  <GreetingCard />
</template>

Global Registration (App-wide — Use Sparingly)

// main.js — component available everywhere, no import needed
import { createApp } from "vue";
import App from "./App.vue";
import GreetingCard from "./components/GreetingCard.vue";

const app = createApp(App);
app.component("GreetingCard", GreetingCard);  // Now available in ALL components
app.mount("#app");

Local vs global: Prefer local — it makes dependencies explicit. Global registration hides where components come from and hurts tree-shaking (the build tool can’t remove unused components).

Props — Parent to Child Communication

Props are like function parameters for components. The parent passes data down, and the child declares what it expects. This creates a one-way data flow — data goes down, never up:

<!-- Child: ChildCard.vue -->
<template>
  <div class="card">
    <!-- Props are accessible as properties on `this`.
         title, description, color, category all come
         from the parent via the props declaration. -->
    <h3>{{ title }}</h3>
    <p>{{ description }}</p>
    <span class="badge" :style="{ background: color }">{{ category }}</span>
  </div>
</template>

<script>
export default {
  name: "ChildCard",
  // Props declaration — like defining function parameters.
  // Each prop specifies the expected type and whether it's required.
  props: {
    title: { type: String, required: true },     // MUST be provided
    description: { type: String, default: "No description" },  // Optional with default
    color: { type: String, default: "#42b883" },
    category: String     // Shorthand — just the type, optional by default
  }
};
</script>

<!-- Parent  passing data down via attributes -->
<template>
  <ChildCard
    title="Vue Basics"
    description="Learn the fundamentals"
    color="#e74c3c"
    category="Beginner"
  />
</template>

Prop Validation

props: {
  items: {
    type: Array,
    required: true,
    // Custom validator — runs in development mode only.
    // Return false to show a console warning.
    validator(value) {
      return value.length > 0;
    }
  },
  count: {
    type: Number,
    default: 0
  },
  user: {
    type: Object,
    default: () => ({})   // Object/array defaults MUST be factory functions.
                           // Reason: otherwise all instances share the same object reference.
  }
}

Why validate? Validation catches bugs early. If someone passes a string where you expect a number, Vue warns you in development — saving hours of debugging. In production, validators are stripped out for performance.

$emit — Child to Parent Communication

Props flow down. Events flow up. Think of it like shouting up a staircase: the child shouts (emits an event), and the parent hears (listens for the event). The event can carry a payload — data the child sends along with the event:

<!-- Child: TodoItem.vue -->
<template>
  <li>
    <span>{{ text }}</span>
    <!-- $emit fires a custom event.
         First argument: event name (string).
         Second argument: payload (any data). -->
    <button @click="$emit('delete', id)">Delete</button>
    <button @click="$emit('toggle', id)">Toggle</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: {
    id: Number,
    text: String
  },
  // Declare emits — documents the component's events (best practice).
  // This also prevents the emit from falling through as a native event.
  emits: ["delete", "toggle"]
};
</script>

<!-- Parent  listening for events with @event-name syntax -->
<template>
  <ul>
    <TodoItem
      v-for="todo in todos"
      :key="todo.id"
      :id="todo.id"
      :text="todo.text"
      @delete="removeTodo"     <!-- Parent handles the delete event -->
      @toggle="toggleTodo"     <!-- Parent handles the toggle event -->
    />
  </ul>
</template>

Emit with Validation

emits: {
  submit(payload) {
    // Return true if valid, false to trigger warning
    return payload && payload.email && payload.password;
  }
}

Putting It Together — Props Down, Events Up

    flowchart TD
    A[Parent Component] -->|Props: data down| B[Child Component]
    B -->|$emit: events up| A
    A -->|Slots: content injection| B

    style A fill:#42b883,color:#fff
    style B fill:#e8f5e9
  

This one-way data flow is intentional. Parent owns the data, child displays and notifies. No confusion about who changed what.

Props and emits comparison:

DirectionMechanismRead/WriteAnalogy
Parent → ChildPropsRead-only in childEmail attachment you open but can’t edit
Child → Parent$emitWrite-only from childPhone call: child shouts up, parent hears

Slots — Content Distribution

Slots are like USB ports on a component. The component provides the port, and the parent plugs in whatever content it wants. This lets the parent control what renders inside the child:

<!-- BaseLayout.vue  provides named ports for content -->
<template>
  <div class="layout">
    <header>
      <!-- name="header"  named slot with default fallback.
           If the parent provides #header content, use that.
           Otherwise, show "Default Header". -->
      <slot name="header">Default Header</slot>
    </header>
    <main>
      <!-- Default (unnamed) slot  catch-all content.
           Any content not wrapped in a named template
           goes here automatically. -->
      <slot>Default Content</slot>
    </main>
    <footer>
      <!-- Named slot, no default (shows nothing if not provided) -->
      <slot name="footer" />
    </footer>
  </div>
</template>

<!-- Usage  plugging content into ports -->
<BaseLayout>
  <!-- #header is shorthand for v-slot:header.
       This content replaces the header slot. -->
  <template #header>
    <h1>My Site</h1>
  </template>

  <!-- This goes into the default (unnamed) slot automatically -->
  <p>This is the main content area.</p>

  <template #footer>
    <p>&copy; 2026 DodaTech</p>
  </template>
</BaseLayout>

Scoped Slots — Child Sends Data Back to Parent’s Template

Regular slots send content from parent to child. Scoped slots let the child send data back to the parent’s template — the child controls the data, the parent controls how it renders:

<!-- ListRenderer.vue  provides item data for the parent's template -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- :item="item" exposes the item data to the parent.
           The parent can then render it however it wants.
           The syntax is: :propName="value" on the <slot> element. -->
      <slot name="item" :item="item" :index="item.index">
        {{ item.name }}   <!-- Fallback if parent doesn't provide content -->
      </slot>
    </li>
  </ul>
</template>

<script>
export default {
  props: { items: Array }
};
</script>

<!-- Usage  parent controls rendering, child provides data -->
<ListRenderer :items="products">
  <!-- { item, index } receives data from the child's scoped slot.
       This is called "slot props"  like arguments passed to the template. -->
  <template #item="{ item, index }">
    <strong>{{ index + 1 }}.</strong> {{ item.name }}  ${{ item.price }}
    <button @click="addToCart(item)">Add</button>
  </template>
</ListRenderer>

Why scoped slots? The child controls the data, the parent controls the presentation. Perfect for reusable list renderers, data tables, and dropdown menus where you need custom rendering.

Dynamic Components

Switch between components using <component :is="..."> — like a component vending machine:

<template>
  <div>
    <button
      v-for="tab in tabs"
      :key="tab"
      @click="currentTab = tab"
      :class="{ active: currentTab === tab }"
    >{{ tab }}</button>

    <!-- :is tells Vue which component to render.
         When the computed property returns a different component,
         Vue destroys the old one and creates the new one.
         Without KeepAlive, the component's state is lost. -->
    <component :is="currentComponent" />
  </div>
</template>

<script>
import TabHome from "./TabHome.vue";
import TabSettings from "./TabSettings.vue";
import TabProfile from "./TabProfile.vue";

export default {
  data() {
    return {
      currentTab: "Home",
      tabs: ["Home", "Settings", "Profile"]
    };
  },
  computed: {
    // Maps tab names to component objects
    currentComponent() {
      const map = {
        Home: TabHome,
        Settings: TabSettings,
        Profile: TabProfile
      };
      return map[this.currentTab];
    }
  }
};
</script>

KeepAlive — Preserving Component State

Without KeepAlive, dynamic components are destroyed and re-created every time you switch. Any form input, scroll position, or loaded data is lost:

<!-- Without KeepAlive: state lost on each switch -->
<component :is="currentComponent" />

<!-- With KeepAlive: components stay alive, state preserved -->
<KeepAlive>
  <component :is="currentComponent" />
</KeepAlive>

Use KeepAlive when switching between tabs with forms, loaded data, or complex state.

Provide / Inject — Deep Component Communication

For deeply nested components, passing props through 5 levels (prop drilling) is painful. Provide/inject skips the middle components:

<!-- Ancestor (top-level component) -->
<script>
import { provide } from "vue";

export default {
  setup() {
    // provide(key, value) — makes data available to ALL descendants
    // at any depth. Like a radio broadcast.
    provide("theme", "dark");
    provide("user", { name: "Alice", role: "admin" });
  }
};
</script>

<!-- Any descendant (at any depth  no need to pass through middle) -->
<script>
import { inject } from "vue";

export default {
  setup() {
    // inject(key, default) — retrieves data from the nearest ancestor
    // that provides it. The second arg is a default value if not found.
    const theme = inject("theme", "light");
    const user = inject("user");
    return { theme, user };
  }
};
</script>

<template>
  <div :class="theme">Welcome, {{ user.name }}</div>
</template>

Options API Style

// Ancestor
export default {
  provide() {
    return {
      theme: computed(() => this.theme),  // Wrap in computed() for reactivity!
      user: this.user
    };
  }
};

// Descendant
export default {
  inject: ["theme", "user"]
};

Provide/inject vs props — when to use which:

PatternBest forDownside
PropsDirect parent-child (1-2 levels)Prop drilling at 4+ levels
Provide/injectDeep trees (theme, auth, locale)Harder to trace where data comes from
Pinia (global store)App-wide state (cart, auth, notifications)Heavier setup, more boilerplate

Fallthrough Attributes

Attributes passed to a component that aren’t declared as props or emits automatically fall through to the root element:

<!-- MyButton.vue  root element is <button> -->
<template>
  <button class="my-btn">
    <slot />
  </button>
</template>

<!-- Usage  class and @click fall through to the inner <button> -->
<MyButton class="large" @click="handleClick">
  Click Me
</MyButton>

To disable fallthrough:

export default {
  inheritAttrs: false
};

Access fallthrough programmatically with $attrs:

<template>
  <div>
    <input v-bind="$attrs" />
  </div>
</template>

Scoped Styling

The scoped attribute limits CSS to the current component. Vue adds a unique data attribute (like data-v-abc123) to elements and rewrites CSS selectors:

<style scoped>
/* Only affects THIS component's elements */
.card { border-radius: 8px; }
.title { font-size: 1.5rem; }
</style>

To style child components from a scoped parent, use :deep():

<style scoped>
:deep(.child-class) {
  color: red;
}
</style>

Template Refs

Get direct access to a DOM element or child component — useful for imperative DOM operations like focus, scroll, or text selection:

<template>
  <!-- ref="nameInput" creates this.$refs.nameInput.
       The ref name becomes a property on this.$refs. -->
  <input ref="nameInput" />
  <ChildComponent ref="childRef" />
  <button @click="focusInput">Focus</button>
</template>

<script>
export default {
  mounted() {
    // Access the native DOM element and call focus()
    this.$refs.nameInput.focus();
    // Access child component instance
    console.log(this.$refs.childRef.someMethod());
  },
  methods: {
    focusInput() {
      this.$refs.nameInput.focus();
    }
  }
};
</script>

When to use refs: For imperative DOM operations (focus, scroll, text selection) or calling child methods directly. Avoid overusing — prefer props/events for most communication.

Common Mistakes

1. Mutating props directly

<!--  Bad: child modifies prop directly -->
<input v-model="value" />
<script>export default { props: ["value"] };</script>

<!--  Good: emit event instead -->
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />

2. Forgetting the emits declaration

Declaring emits documents the component’s events and prevents native DOM events from falling through.

3. Missing :key in v-for inside components

Always use :key with v-for — reuse bugs in list components are especially hard to debug.

4. Overusing provide/inject

Provide/inject makes data flow hard to trace. Prefer props for parent-child and Pinia for global state.

5. No KeepAlive on dynamic components with forms

Without KeepAlive, switching tabs destroys form state. Users lose their input silently.

6. Scoped styles blocking overrides

Scoped CSS makes it hard to customize child components. Use :deep() or global CSS for design tokens.

Practice Questions

1. What direction do props flow? Parent to child. Props are read-only in the child — never mutate them directly.

2. How does a child send data to its parent? Using $emit to fire a custom event with a payload. The parent listens with @event-name.

3. What’s the difference between a slot and a scoped slot? A regular slot injects parent content into the child. A scoped slot lets the child provide data back to the parent’s template.

4. When should you use provide/inject? For deeply nested component trees where prop drilling becomes cumbersome. Avoid for direct parent-child.

5. What does KeepAlive do? Preserves component state when dynamic components are switched. Without it, switching destroys and recreates the component.

Challenge: Build a tabbed interface with three tabs (Profile, Settings, Notifications). Each tab has a form. Use KeepAlive to preserve form state across tab switches. Bonus: add an “unsaved changes” warning when switching tabs.

FAQ

What are props in Vue?
Props are custom attributes passed from parent to child components. They are read-only and should not be mutated by the child.
How do I send data from child to parent?
Use $emit to fire a custom event from the child with a payload. The parent listens with @event-name.
What is the difference between props and slots?
Props pass data as attributes. Slots pass template content that the parent controls. Scoped slots let the child provide data back.
When should I use provide/inject?
For deeply nested component communication where prop drilling becomes cumbersome. Prefer props for direct parent-child.
What is KeepAlive?
A built-in component that preserves the state of dynamic components when they are switched out.
How do I access a child component from the parent?
Use ref on the child element and access it via this.$refs.refName in Options API or template refs in Composition API.

Try It Yourself

Copy this into a new HTML file and open it in your browser:

<!DOCTYPE html>
<html>
<head>
  <title>Vue Components Demo</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; }
    .card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 12px 0; }
    .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; color: white; font-size: 12px; }
    button { background: #42b883; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; margin-top: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <h1>Product Cards</h1>
    <div v-for="product in products" :key="product.id" class="card">
      <h3>{{ product.name }}</h3>
      <p>{{ product.description }}</p>
      <span class="badge" :style="{ background: product.color }">{{ product.category }}</span>
      <br>
      <button @click="handleBuy(product.id)">Buy ${{ product.price }}</button>
    </div>
    <p v-if="cart.length">Cart: {{ cart.join(", ") }}</p>
  </div>

  <script>
    const { createApp } = Vue;
    createApp({
      data() {
        return {
          cart: [],
          products: [
            { id: 1, name: "Vue Mug", description: "Ceramic mug", price: 12.99, category: "Drinkware", color: "#42b883" },
            { id: 2, name: "JS T-Shirt", description: "Cotton tee", price: 24.99, category: "Apparel", color: "#f0db4f" }
          ]
        };
      },
      methods: {
        handleBuy(id) {
          const p = this.products.find(x => x.id === id);
          if (p) this.cart.push(p.name);
        }
      }
    }).mount("#app");
  </script>
</body>
</html>

What to try: Click “Buy” on a product to see how parent-child communication works through events. Extend this by extracting a ProductCard component with props and emits.

What’s Next

ResourceDescription
https://tutorials.dodatech.com/frameworks/vue/vue-router/Client-side routing with Vue Router
https://tutorials.dodatech.com/frameworks/vue/vue-advanced/Composition API, Pinia, advanced patterns
JavaScriptLearn modern JavaScript for Vue apps
TypeScriptAdd types to your Vue components

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

What’s Next

Congratulations on completing this Vue Components tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro