Composing Components
One component can only get you so far. Real applications are trees of components, each one small and focused, passing data down and events up. This chapter covers the vocabulary of that tree — props, emits, slots, composables, and provide/inject — and a pattern specific to apps like the ones in this course: navigating without a router.
Props down, emits up #
A parent component passes data to a child through props. A child never reaches back up and mutates its parent's data directly; instead it emits an event, and the parent decides what to do about it. This one-way flow — down for data, up for events — is what keeps a large component tree predictable. See Vue's props guide for the full reference.
TodoItem.vue — a typed child component<script setup lang="ts">
const props = defineProps<{
id: number;
title: string;
done: boolean;
}>();
const emit = defineEmits<{
markDone: [id: number];
}>();
</script>
<template>
<li>
<span :class="{ done: props.done }">{{ props.title }}</span>
<button @click="emit('markDone', props.id)">Done</button>
</li>
</template>
TodoList.vue — the parent, listening for the event
<script setup>
import TodoItem from "./TodoItem.vue";
import { todos, markDone } from "./useTodos";
</script>
<template>
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:id="todo.id"
:title="todo.title"
:done="todo.done"
@mark-done="markDone"
/>
</ul>
</template>
defineProps<{...}>() and defineEmits<{...}>()
are generic
functions: the type inside the angle brackets tells the compiler exactly what shape of props and events
this component accepts, so passing the wrong prop type is a compile-time error, not a runtime surprise.
The emit is declared and called as markDone (camelCase), but the parent listens for
it as @mark-done (kebab-case). Vue converts automatically between the two in templates,
but only in that direction — write @markDone in the parent template and it simply
won't fire, with no warning. When in doubt, listen in kebab-case; it matches how the event appears as
an HTML attribute.
Slots, briefly #
Props pass data; a
slot
passes markup. A generic Card component might not know what content goes inside it — it just reserves a spot:
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>
<!-- usage -->
<Card>
<h3>Weather</h3>
<p>72°F, clear</p>
</Card>
Everything between <Card> and </Card> renders wherever
<slot /> appears inside Card.vue. It's how you build reusable
"container" components — cards, modals, panels — without hardcoding their contents.
Composables: reusable stateful logic #
Props and emits organize how components talk to each other. But what about logic that has nothing
to do with a specific piece of markup — like managing a list of todos, or tracking a counter? A
composable
is a plain function, conventionally named useSomething, that bundles refs and functions
together so multiple components can share the same logic. See Vue's
composables guide.
import { ref, computed } from "vue";
export function useTodos() {
const todos = ref<{ id: number; title: string; done: boolean }[]>([]);
const remaining = computed(() =>
todos.value.filter((t) => !t.done).length
);
function addTodo(title: string) {
todos.value.push({ id: Date.now(), title, done: false });
}
function markDone(id: number) {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = true;
}
return { todos, remaining, addTodo, markDone };
}
Any component can now call const { todos, remaining, addTodo, markDone } = useTodos();
and get its own independent copy of that state and logic — no inheritance, no mixins, just a
function call. This is the same shape as a small counter composable, which is often the first one
people write:
import { ref } from "vue";
export function useCounter(initial = 0) {
const count = ref(initial);
function increment() { count.value++; }
function reset() { count.value = initial; }
return { count, increment, reset };
}
Avoiding prop drilling with provide/inject #
Composables solve reuse, but not every value needs to be reusable — sometimes you just have one piece of app-wide state (say, the current logged-in user, or a shared settings object) that a deeply nested component needs to read. Passing it down as a prop through every intermediate component that doesn't itself care about it is called prop drilling, and it makes refactoring painful: change the shape of the data and you touch every layer in between.
provide/inject
solves this: an ancestor component provide()s a value under a key, and any descendant,
no matter how deep, can inject() it directly. Using a typed
injection key
keeps this type-safe instead of relying on a bare string. See Vue's
provide/inject guide.
import type { InjectionKey, Ref } from "vue";
export interface AppState {
currentView: Ref<string>;
user: Ref<{ name: string } | null>;
}
export const AppStateKey: InjectionKey<AppState> = Symbol("app-state");
Symbol("app-state") creates a value guaranteed to be unique — even another key
created with the exact same description string would be a different, non-matching symbol. That
uniqueness is what makes it safe as an injection key: two unrelated libraries can each define their own
key without any risk of accidentally colliding.
<script setup>
import { ref, provide } from "vue";
import { AppStateKey } from "./keys";
const currentView = ref("dashboard");
const user = ref({ name: "Ada" });
provide(AppStateKey, { currentView, user });
</script>
UserBadge.vue — inject several layers down
<script setup>
import { inject } from "vue";
import { AppStateKey } from "./keys";
const appState = inject(AppStateKey)!;
</script>
<template>
<span>{{ appState.user?.name }}</span>
</template>
! is telling TypeScript to trust youinject() actually returns AppState | undefined, because there's no
static guarantee that some ancestor called provide() with this exact key —
forgetting to provide it is a real, easy mistake. The ! (a
non-null assertion) tells the compiler "I promise this isn't undefined," which
silences the type error but adds no actual safety: if the promise is wrong, you get a runtime crash
on appState.user instead of a compile-time warning. Vue's own recommended pattern is
safer and just as short — pass a default as the second argument:
const appState = inject(AppStateKey, { currentView: ref("dashboard"), user: ref(null) });
Now a missing provide() fails gracefully with sensible defaults instead of crashing,
and the type stays AppState, not AppState | undefined, with no !
needed anywhere.
Provide/inject is for genuinely app-wide concerns — the current user, theme, or navigation state. Reach for props first. If you find yourself injecting values that only one or two components actually use, a prop (or a composable) is usually the clearer choice.
One honest tension worth naming: provide/inject makes shared state reachable from anywhere in the subtree, but it doesn't stop any of those components from mutating it directly — nothing here enforces the predictable, one-directional flow this chapter has been building toward. That's exactly the gap Chapter 8 closes: a store built on this same provide/inject mechanism, but with a rule that state can only change through a small set of named actions.
Navigation without a router #
Many multi-page frameworks ship a router: a library that maps URL paths to components, so visiting
/todos/42 renders a specific view. Some apps — particularly dashboard-style,
app-like SPAs
that live behind a login and are never meant to be deep-linked or bookmarked page-by-page — skip the router entirely. Instead, a single reactive value, often called currentView or
currentPage, lives in shared state (provided at the root, as above, or in a store — see Chapter 8). Components read it to decide what to render and
write to it to "navigate":
<script setup>
import { inject } from "vue";
import { AppStateKey } from "./keys";
import DashboardView from "./DashboardView.vue";
import SettingsView from "./SettingsView.vue";
const appState = inject(AppStateKey)!;
</script>
<template>
<nav>
<button @click="appState.currentView.value = 'dashboard'">Dashboard</button>
<button @click="appState.currentView.value = 'settings'">Settings</button>
</nav>
<DashboardView v-if="appState.currentView.value === 'dashboard'" />
<SettingsView v-else-if="appState.currentView.value === 'settings'" />
</template>
This trades away deep-linking — you can't bookmark or share a URL for a specific screen, and the browser's back button won't navigate between views — in exchange for simplicity: no route configuration, no nested route matching, no separate concept of "page" versus "component." For an app-like tool where users arrive at one entry point and everything happens in one session, that trade is often worth it. For a public, content-driven site where individual pages need their own URLs, a router is usually the better fit.
Router-based navigation
URL is the source of truth. Supports deep-linking, bookmarking, back/forward. Adds route configuration and a routing library as a dependency.
State-driven navigation
A reactive value is the source of truth. Simpler setup, no extra dependency. No deep-linking; the URL never reflects what's on screen.
Extend the currentView example with a third view and a v-else-if
branch. Then try adding a previousView ref that the nav buttons set before switching — that's roughly how you'd hand-roll a "back" button without a router.