A Vue component owns a focused piece of interface, its local state, and the behavior needed to render it. In a build-based Vue 3 application, components usually live in .vue Single-File Components. Parents send data down through props; children report events upward. This one-way contract keeps reusable pieces understandable.
After this lesson, you can split a page at a meaningful boundary, declare props and emitted events with <script setup>, use slots for parent-owned markup, and diagnose state that was placed in the wrong component.
Components are reusable, self-contained pieces of UI. In Vue 3, components are defined as Single File Components (.vue files) containing template, script, and style sections.
<!-- components/AppButton.vue -->
<template>
<button
:class="['btn', `btn-${variant}`, `btn-${size}`, { loading }]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="spinner"></span>
<slot>{{ label }}</slot>
</button>
</template>
<script setup>
// defineProps - declare accepted props
const props = defineProps({
label: { type: String, default: 'Click me' },
variant: { type: String, default: 'primary', validator: v => ['primary','secondary','danger'].includes(v) },
size: { type: String, default: 'md' },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
})
// defineEmits - declare emitted events
const emit = defineEmits(['click'])
</script>
<style scoped>
.btn { padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #42b883; color: white; }
.btn-secondary { background: #6c757d; color: white; }
.btn-danger { background: #dc3545; color: white; }
.btn-sm { padding: 4px 8px; font-size: 0.875rem; }
.btn-lg { padding: 12px 24px; font-size: 1.125rem; }
.loading { opacity: 0.7; cursor: not-allowed; }
</style>
<!-- components/AppCard.vue - using slots -->
<template>
<div class="card">
<!-- Named slot: header -->
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<!-- Default slot: body content -->
<div class="card-body">
<slot />
</div>
<!-- Named slot: footer -->
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- Scoped slot - pass data from child to parent -->
<!-- components/DataList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<!-- Pass item data to parent via scoped slot -->
<slot :item="item" :index="items.indexOf(item)" />
</li>
</ul>
</template>
<script setup>
defineProps({ items: Array })
</script>
<!-- Parent.vue - using components -->
<template>
<div>
<!-- Using AppButton -->
<AppButton label="Save" variant="primary" @click="save" />
<AppButton variant="danger" :loading="isDeleting" @click="deleteItem">
Delete <!-- slot content overrides label -->
</AppButton>
<!-- Using AppCard with named slots -->
<AppCard>
<template #header>
<h3>User Profile</h3>
</template>
<!-- Default slot -->
<p>Name: Alice</p>
<p>Email: alice@example.com</p>
<template #footer>
<AppButton label="Edit" size="sm" />
</template>
</AppCard>
<!-- Using scoped slot -->
<DataList :items="users">
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }}</span>
</template>
</DataList>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AppButton from './AppButton.vue'
import AppCard from './AppCard.vue'
import DataList from './DataList.vue'
const isDeleting = ref(false)
const users = ref([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }])
function save() { console.log('Saved!') }
async function deleteItem() {
isDeleting.value = true
await new Promise(r => setTimeout(r, 1000))
isDeleting.value = false
}
</script>
Put state in the nearest component that must coordinate it. A child may own temporary UI state such as whether its menu is open. A parent should own a selected product when several children read or change that selection. Pass the value as a prop and emit an event describing what happened instead of mutating the prop.
Props are read-only from the child perspective. If a child needs an editable draft, initialize local state from the prop and decide how later prop changes should synchronize. Blindly copying every prop creates two sources of truth and subtle stale-state bugs.
Declare emitted events with defineEmits so the public component contract is visible. Prefer event names that describe an outcome, such as saved or quantityChanged, rather than implementation details such as buttonClicked. The parent decides how that outcome changes application state.
Use a slot when the child owns layout or behavior but the parent owns the inserted content. Props are appropriate for typed data; slots are appropriate for markup. A component with dozens of display props may be easier to use when a small number of named slots expose the intended extension points.
Render multiple instances to verify local state is not accidentally shared. Each component instance receives its own local reactive state. Shared module-level reactive objects are different: every importer can observe the same object, which should be a deliberate store or composable decision.
Try this next
0 of 2 completed
Explore 500+ free tutorials across 20+ languages and frameworks.