Vue Components Explained — Props, Slots, and Communication
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
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:
| Direction | Mechanism | Read/Write | Analogy |
|---|---|---|---|
| Parent → Child | Props | Read-only in child | Email attachment you open but can’t edit |
| Child → Parent | $emit | Write-only from child | Phone 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>© 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:
| Pattern | Best for | Downside |
|---|---|---|
| Props | Direct parent-child (1-2 levels) | Prop drilling at 4+ levels |
| Provide/inject | Deep 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
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
| Resource | Description |
|---|---|
| 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 |
| JavaScript | Learn modern JavaScript for Vue apps |
| TypeScript | Add 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