π§© Vue Component System
Components are the atoms of every Vue application β small, self-contained bundles of template, logic, and style that snap together into full interfaces. In this lesson you'll build them the modern way, register them cleanly, follow their lifecycle from birth to teardown, and swap them on the fly with dynamic components.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define a Single-File Component with
<script setup>and explain why SFCs are the recommended format - Choose between global and local registration and justify the trade-offs for bundle size
- Trace a component through its lifecycle hooks and place setup/cleanup code in the right hook
- Swap views at runtime with dynamic components and preserve their state using
<KeepAlive> - Assemble a small UI by composing several focused components together
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a reusable BaseButton component and drop several configured instances into a page.
In This Lesson
What Is a Component?
A component is a reusable, self-contained piece of an interface. It packages its own markup (what it looks like), its own logic (how it behaves), and β in Vue β often its own scoped styles, all in one place. Instead of building a page as one giant file, you build a tree of small components that each do one job well.
π‘ A useful analogy: Components are like LEGO bricks. Each brick has a defined shape and connection points, and you assemble complex structures by clicking simple bricks together. You rarely think about the plastic β you think about the shape you want and reach for the brick that fits. Vue components work the same way: build the brick once, then click copies of it wherever you need them.
A real application is a hierarchy of these bricks. The root component holds a few large sections; those sections hold smaller pieces; and the smallest leaves render a single button or rating star.
A typical Vue component hierarchy. Each box is an independent, reusable component.
Why Components?
Breaking an interface into components is not busywork β it buys you four concrete advantages that compound as your app grows.
π The four benefits
Reusability: Write a BaseButton once and use it in dozens of places. Fix a bug or change its styling in one file and every button updates.
Maintainability: Ten focused 50-line components are far easier to reason about than one 500-line file. Each has a single, obvious responsibility.
Encapsulation: A dropdown manages its own open/closed state. The rest of the app neither knows nor cares how it works internally β fewer surprises, easier debugging.
Composition: Complex screens are assembled from simple parts. A cart page is cart-item components, which contain image, quantity, and price components.
The mental shift is this: you stop thinking "how do I build this whole page?" and start thinking "what are the small, nameable pieces, and how do they fit together?"
Single-File Components
The standard, recommended way to author a Vue component is the Single-File Component (SFC) β a file ending in .vue that holds the template, script, and styles for one component together. Modern Vue 3 code uses the <script setup> syntax with the Composition API, which is the most concise and best-typed option.
<!-- BaseButton.vue -->
<template>
<button class="base-button" @click="handleClick">
{{ text }}
</button>
</template>
<script setup>
// Declare the props this component accepts
const props = defineProps({
text: { type: String, default: 'Click me' }
})
// Declare the events this component can emit
const emit = defineEmits(['click'])
function handleClick() {
emit('click')
}
</script>
<style scoped>
.base-button {
background-color: #42b883;
color: white;
border: none;
padding: 0.6rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.base-button:hover {
background-color: #35495e;
}
</style>
β Why SFCs win
- Related code (HTML, CSS, JS) lives together in one file β easy to find, easy to move.
- Editors give you full syntax highlighting and autocomplete for all three languages.
<style scoped>keeps a component's CSS from leaking out and clobbering the rest of the app.- The build step compiles templates ahead of time, so runtime is fast.
Options API vs. Composition API
You will still meet the older Options API in tutorials and legacy code, where logic is split into data, methods, and computed buckets. This course uses the Composition API with <script setup>, which groups code by feature instead of by option type. Here is the same counter both ways:
| Options API (older) | Composition API with <script setup> (this course) |
|---|---|
|
|
Notice how the Composition API keeps the count, its computed value, and its increment function together as one unit β that grouping pays off enormously in large components. You'll go deep on the Composition API in a later lesson; for now, just recognize the ref/computed/onMounted pattern.
Registering Components
Before you can use a component in a template, Vue needs to know it exists. You register it either locally (in the one component that uses it) or globally (available everywhere).
Local registration (preferred)
With <script setup>, local registration is automatic: you simply import the child component and use it in the template. No components: { ... } block is needed.
<!-- ParentView.vue -->
<template>
<BaseButton text="Save" @click="save" />
</template>
<script setup>
import BaseButton from './BaseButton.vue'
function save() {
console.log('saved!')
}
</script>
Global registration (use sparingly)
Global components are registered once on the app instance and can be used in any template without importing. This is convenient for a handful of truly ubiquitous components, but overusing it bloats your bundle and hides dependencies.
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
// Now <BaseButton> works in every template, no import needed
app.component('BaseButton', BaseButton)
app.mount('#app')
β οΈ The trade-off
Globally registered components are included in your final bundle even if no page uses them, because the build tool cannot tree-shake them away. Local registration keeps dependencies explicit and lets the bundler drop unused code. Reach for global registration only for components used on nearly every screen (a design-system button or icon), and prefer local registration for everything else.
The Component Lifecycle
Every component instance is born, mounted to the page, updated as data changes, and eventually torn down. Vue lets you hook into each of these moments to run code at exactly the right time. In the Composition API, the hooks are imported functions that you call inside <script setup>.
The lifecycle of a component, from creation through updates to teardown.
Which hook for which job?
| Hook | Fires when⦠| Good for |
|---|---|---|
onMounted | the component is inserted into the DOM | DOM access, initializing charts/maps, adding listeners |
onUpdated | the DOM re-renders after a reactive change | reacting to the freshly rendered DOM (use sparingly) |
onBeforeUnmount | the component is about to be removed | cleanup: removing listeners, destroying instances, clearing timers |
The most important pairing is set up in onMounted, tear down in onBeforeUnmount. Anything you attach to the outside world β a resize listener, a third-party chart, an interval β must be cleaned up, or you leak memory every time the component mounts and unmounts.
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const width = ref(window.innerWidth)
function handleResize() {
width.value = window.innerWidth
}
onMounted(() => {
// The DOM exists now β safe to touch it or attach listeners
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
// Always undo what you did in onMounted
window.removeEventListener('resize', handleResize)
})
</script>
π‘ Where do API calls go?
Fetching data does not need the DOM, so you can start it at the top level of <script setup> (which runs during creation) or inside onMounted. Both are common. What matters is that you don't need to wait for mounting to begin a fetch β only to touch rendered elements.
Dynamic Components & KeepAlive
Sometimes you want to render different components in the same spot depending on state β think of a tabbed interface. Vue's built-in <component :is="..."> element does exactly this: give it a component (or its name) and it renders that one.
<template>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.name"
:class="{ active: currentTab === tab.name }"
@click="currentTab = tab.name"
>
{{ tab.label }}
</button>
<!-- Renders whichever component currentComponent points to -->
<component :is="currentComponent" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import HomeTab from './tabs/HomeTab.vue'
import ProfileTab from './tabs/ProfileTab.vue'
import SettingsTab from './tabs/SettingsTab.vue'
const tabs = [
{ name: 'home', label: 'Home', component: HomeTab },
{ name: 'profile', label: 'Profile', component: ProfileTab },
{ name: 'settings', label: 'Settings', component: SettingsTab }
]
const currentTab = ref('home')
const currentComponent = computed(
() => tabs.find(t => t.name === currentTab.value).component
)
</script>
Preserving state with KeepAlive
By default, switching away from a dynamic component destroys it, and switching back recreates it β losing any state, such as text a user typed into a form. Wrap the dynamic component in <KeepAlive> to cache it instead:
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
Cached components gain two extra lifecycle hooks β onActivated (shown again) and onDeactivated (hidden but kept) β which are perfect for refreshing data or pausing a video when a tab loses focus.
β Real-world use
<KeepAlive> shines for multi-step forms and tabbed panels: users can flip between tabs without losing half-filled inputs or their scroll position.
Worked Example: A Reusable Modal
Let's tie the concepts together with a small but genuinely reusable modal dialog. It shows off props (for configuration), events (to talk back to its parent), slots (to project content), and lifecycle hooks (to wire up an Escape-key handler and clean it up).
<!-- BaseModal.vue -->
<template>
<Transition name="modal-fade">
<div
v-if="isOpen"
class="modal-overlay"
@click="closeOnBackdrop && close()"
>
<div class="modal" @click.stop>
<header class="modal-header">
<h3>{{ title }}</h3>
<button class="modal-close" @click="close">×</button>
</header>
<div class="modal-body">
<!-- Default slot: the parent decides what goes here -->
<slot>No content provided.</slot>
</div>
<footer class="modal-footer">
<!-- Named slot with a sensible fallback -->
<slot name="footer">
<button @click="close">Cancel</button>
<button @click="confirm">Confirm</button>
</slot>
</footer>
</div>
</div>
</Transition>
</template>
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
title: { type: String, default: 'Dialog' },
isOpen: { type: Boolean, default: false },
closeOnBackdrop: { type: Boolean, default: true }
})
const emit = defineEmits(['close', 'confirm'])
function close() {
emit('close')
}
function confirm() {
emit('confirm')
}
// Close on the Escape key β set up and cleaned up via lifecycle hooks
function onKeydown(event) {
if (event.key === 'Escape' && props.isOpen) close()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
</script>
And here is a parent using it. Notice how the parent controls visibility with a ref, listens for @close/@confirm, and projects both body content and a custom footer:
<template>
<button @click="showModal = true">Delete account</button>
<BaseModal
title="Are you sure?"
:is-open="showModal"
@close="showModal = false"
@confirm="handleDelete"
>
<p>This will permanently delete your account. This cannot be undone.</p>
<template #footer>
<button @click="showModal = false">Keep my account</button>
<button class="danger" @click="handleDelete">Yes, delete it</button>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue'
import BaseModal from './BaseModal.vue'
const showModal = ref(false)
function handleDelete() {
console.log('account deleted')
showModal.value = false
}
</script>
One small component, and it already demonstrates the pillars of the component system: encapsulation (it owns its overlay markup and key handler), props for configuration, events for communication, slots for flexible content, and lifecycle hooks for setup and cleanup.
Hands-on Exercise
ποΈ Build a Configurable BaseButton
Objective: Create one reusable button component and prove its reusability by dropping several differently-configured instances onto a page.
Requirements:
- Create
BaseButton.vueusing<script setup>. - Accept props:
label(String),variant(String:'primary' | 'secondary' | 'danger', default'primary'), anddisabled(Boolean, defaultfalse). - Emit a
clickevent when pressed (but not while disabled). - Bind a CSS class from the
variantprop so each variant looks different. - In a parent component, render three buttons β one of each variant β and log which one was clicked.
π‘ Hint
Use a dynamic class binding like :class="['base-button', variant]" so the variant name becomes a CSS class. Bind :disabled="disabled" on the native <button> so the browser blocks clicks for free, and only emit('click') from your handler.
β Solution
<!-- BaseButton.vue -->
<template>
<button
:class="['base-button', variant]"
:disabled="disabled"
@click="emit('click')"
>
{{ label }}
</button>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
variant: { type: String, default: 'primary' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['click'])
</script>
<style scoped>
.base-button { padding: 0.5rem 1rem; border: none; border-radius: 4px; color: #fff; cursor: pointer; }
.base-button:disabled { opacity: 0.5; cursor: not-allowed; }
.primary { background: #42b883; }
.secondary { background: #35495e; }
.danger { background: #e74c3c; }
</style>
<!-- ParentView.vue -->
<template>
<BaseButton label="Save" variant="primary" @click="onClick('save')" />
<BaseButton label="Cancel" variant="secondary" @click="onClick('cancel')" />
<BaseButton label="Delete" variant="danger" @click="onClick('delete')" />
</template>
<script setup>
import BaseButton from './BaseButton.vue'
function onClick(which) {
console.log('clicked:', which)
}
</script>
Best Practices
| β Do | π« Don't |
|---|---|
| Give each component one clear responsibility | Let a single component grow into a 500-line "god component" |
| Prefer local registration so dependencies are explicit | Register everything globally "just in case" |
Name components with multi-word PascalCase (UserCard) | Use single-word names that can clash with HTML elements |
Clean up listeners/timers in onBeforeUnmount | Attach global listeners in onMounted and forget to remove them |
Use <KeepAlive> when a hidden view must keep its state | Assume a hidden dynamic component keeps its data (it's destroyed by default) |
π― Quick Quiz
Question 1: Why is local registration generally preferred over global registration?
Question 2: You attach a window resize listener in onMounted. Where should you remove it?
Question 3: A user types into a form on one tab, switches tabs, and switches back to find the form empty. What fixes this?
Summary & Quiz
π Key Takeaways
- A component bundles template, logic, and scoped style into one reusable, self-contained unit.
- Author components as Single-File Components with
<script setup>and the Composition API. - Prefer local registration (auto with
<script setup>); use global registration only for truly ubiquitous components. - The lifecycle runs setup β mount β update β unmount; pair
onMountedsetup withonBeforeUnmountcleanup. <component :is>swaps components at runtime; wrap it in<KeepAlive>to preserve state.
π Further Reading
- Vue.js β Components Basics
- Vue.js β Component Registration
- Vue.js β Lifecycle Hooks (Composition API)
- Vue.js β KeepAlive
π What's Next?
Components are only useful once they can talk to each other. Next up: Props, Events, and Communication β the precise rules for passing data down, sending events up, and building two-way bindings with v-model.
π Nice work!
You can now build and compose components. Let's teach them to communicate.