● David R. Longnecker
Chapter 7 · Frontend

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.

Watch the casing

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:

using a slot
<!-- 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.

useTodos.ts
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:

useCounter.ts
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.

Prop drilling through every layer versus provide at the root and inject at a deep leaf Prop drilling App Layout Sidebar UserBadge user user user provide / inject App provide(user) Layout Sidebar UserBadge inject(user)
Left: every layer must accept and forward a prop it doesn't use. Right: the root provides once, and the leaf injects directly, skipping the layers in between.
keys.ts — a typed injection key
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.

App.vue — provide at the root
<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>
That trailing ! is telling TypeScript to trust you

inject() 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.

Watch out

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":

state-driven view switching
<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.

Try it

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.