3. The Components
So far, each example has fit into a single component, [App.vue]. A real application contains dozens of them: a navigation bar, forms, windows... Each component is a self-contained piece of the interface (its HTML, its code, its style) and is reusable. This chapter shows how components fit together and communicate.
3.1. Example 07: Components and Their Props
The example displays one card per doctor. The card is a component, [DoctorCard.vue], used three times by its parent, [App.vue].
The doctor type, shared by both components, is in a regular [TypeScript] file:
| // the data type shared by the parent (App.vue) and the child (DoctorCard.vue)
export interface Doctor {
id: number;
title: string;
firstName: string;
lastName: string;
specialty: string;
available: boolean;
}
|
The child component:
| <!--
=========================================================================
07-components/src/components/DoctorCard.vue - a CHILD component
-------------------------------------------------------------------------
It receives its data from its parent through PROPS (properties), declared
with defineProps. The TypeScript type of the props is checked at
compile time: <DoctorCard :doctor="42"> would be rejected.
Golden rule: a component NEVER MODIFIES its props. The data
flows down from the parent to the child ("one-way data flow"); to
request a change, the child emits an event (see example 08).
=========================================================================
-->
<script setup lang="ts">
import { computed } from 'vue';
import type { Doctor } from '../doctor';
// withDefaults: a default value for the optional props (?)
const props = withDefaults(
defineProps<{
doctor: Doctor;
rank: number;
compact?: boolean;
}>(),
{ compact: false },
);
// a computed based on a prop: recomputed if the parent changes the prop
const initials = computed(() => `${props.doctor.firstName[0]}${props.doctor.lastName[0]}`);
</script>
<template>
<div class="card h-100" :class="{ 'border-success': doctor.available }">
<div class="card-body d-flex gap-3 align-items-center">
<div class="initials">{{ initials }}</div>
<div>
<!-- in the template, the props are used directly by their name -->
<h2 class="h6 mb-1">{{ rank }}. {{ doctor.title }} {{ doctor.firstName }} {{ doctor.lastName }}</h2>
<p v-if="!compact" class="mb-0 text-muted small">{{ doctor.specialty }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.initials {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #0d6efd;
color: white;
font-weight: bold;
}
</style>
|
- Line 16: [import type] imports only one type: nothing from this import remains in the resulting [JavaScript];
- Lines 19–26: [defineProps<{...}>()] declares the component’s props—that is, the data it receives from its parent—along with their types. A prop followed by [?] is optional; [withDefaults] gives it a default value. [defineProps] does not need to be imported: it is a “macro” recognized by the [.vue] file compiler;
- Line 29: In the script, the props are read via the [props] object. [computed] tracks the props: if the parent changes the assigned doctor, the initials are recalculated;
- line 38: In the template, props are used directly by name;
- line 39: a prop can modify the display.
The parent:
| <!--
=========================================================================
07-components/src/App.vue - the PARENT component
-------------------------------------------------------------------------
It imports the DoctorCard component and uses it as a new
HTML tag, <DoctorCard>, as many times as needed: a component
is a REUSABLE piece of interface, with its own data, its
template and its style.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
// in <script setup>, an imported component is immediately usable in the template
import DoctorCard from './components/DoctorCard.vue';
import type { Doctor } from './doctor';
const doctors = ref<Doctor[]>([
{ id: 1, title: 'Mme', firstName: 'Marie', lastName: 'PELISSIER', specialty: 'general practitioner', available: true },
{ id: 2, title: 'Mr', firstName: 'Paul', lastName: 'TATOU', specialty: 'pediatrician', available: false },
{ id: 3, title: 'Melle', firstName: 'Sophie', lastName: 'DUPONT', specialty: 'general practitioner', available: true },
]);
const compact = ref(false);
// the parent modifies ITS data: the children concerned update themselves
function toggleAvailability(): void {
for (const m of doctors.value) {
m.available = !m.available;
}
}
</script>
<template>
<div class="container py-4">
<h1 class="h3 mb-3">Example 07 - components and their props</h1>
<div class="d-flex gap-3 mb-3">
<label class="form-check-label"><input v-model="compact" type="checkbox" class="form-check-input"> compact display</label>
<button class="btn btn-sm btn-outline-primary" @click="toggleAvailability">Toggle availabilities</button>
</div>
<div class="row g-3">
<div v-for="(m, i) in doctors" :key="m.id" class="col-md-4">
<!-- :doctor="m" passes the OBJECT m (an expression);
compact, without a colon, would pass the STRING "compact" -->
<DoctorCard :doctor="m" :rank="i + 1" :compact="compact" />
</div>
</div>
</div>
</template>
|
- line 14: in [<script setup>], an imported component is immediately usable in the template, like a new tag;
- line 43: Each card receives ITS doctor. [:doctor="m"] (with a colon) passes the value of the expression [m]; without the colon, the character string ["m"] would be passed, which [TypeScript] would reject here. Component tag names are written as [PascalCase] to distinguish them from HTML tags;
- lines 25–29: the parent modifies ITS data; the affected cards update.
The golden rule: a component never modifies its own props. Data flows from the parent to the child ([one-way data flow]): the parent owns it. To request a change, the child emits an event, which the parent processes: this is the subject of the following example.

3.2. Example 08: Component Events and v-model
3.2.1. Triggering an Event
The [Counter] component notifies its parent every time its button is clicked:
| <!--
=========================================================================
08-component-events/src/components/Counter.vue
-------------------------------------------------------------------------
A child that NOTIFIES its parent: defineEmits declares the events
it can emit, with the type of their data. The parent listens to them
like HTML events: <Counter @change="..." @limit="...">.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ label: string; max: number }>();
// two events: 'change' (with the new value) and 'limit' (without data)
const emit = defineEmits<{
change: [value: number];
limit: [];
}>();
const value = ref(0);
function plus(): void {
if (value.value >= props.max) {
emit('limit');
return;
}
value.value++;
emit('change', value.value);
}
</script>
<template>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="w-25">{{ label }}</span>
<button class="btn btn-sm btn-outline-primary" @click="plus">+1</button>
<span class="badge text-bg-secondary">{{ value }} / {{ max }}</span>
</div>
</template>
|
- Lines 16–19: [defineEmits] declares the events that the component can trigger, along with their data types: [change] carries a number, [limit] carries nothing;
- Lines 25 and 29: Emitting an event. [TypeScript] verifies the event name and its data type;
- Line 21: The child is allowed to have its own state: [value] belongs to it, not to the parent.
3.2.2. v-model on a component
The component [TextField] is a “custom” form field: a label, the field itself, a tooltip, and a character counter. We want to use it with [v-model], just like a regular [<input>]:
| <!--
=========================================================================
08-component-events/src/components/TextField.vue
-------------------------------------------------------------------------
A "home-made" form field (label + field + help + character
counter) that accepts v-model, like an ordinary <input>:
<TextField v-model="lastName" label="Last name" :max="20" />
defineModel() creates a ref linked to the parent's v-model: READING it gives the
parent's value, WRITING it emits the 'update:modelValue' event, which the
parent handles by updating its data. The data thus remains
the property of the parent.
=========================================================================
-->
<script setup lang="ts">
import { useId } from 'vue';
const model = defineModel<string>({ required: true });
defineProps<{ label: string; max: number; help?: string }>();
// a unique identifier per instance, to link <label for> and <input id>
const id = useId();
</script>
<template>
<div class="mb-3">
<label class="form-label" :for="id">{{ label }}</label>
<!-- v-model="model": the native field is linked to the ref of defineModel -->
<input :id="id" v-model="model" class="form-control" :maxlength="max" :class="{ 'is-invalid': model.length === max }">
<div class="form-text">{{ help }} ({{ model.length }} / {{ max }})</div>
</div>
</template>
|
- Line 18: [defineModel] creates a [ref] linked to the parent’s [v-model]. Reading it returns the parent’s value; writing to it triggers the [update:modelValue] event, which the parent handles by modifying ITS data. [v-model="lastName"] on a component is simply a shortcut for the [modelValue] property and this event: the data remains the property of the parent;
- line 22: [useId()] provides a unique identifier per component instance: the two [TextField] instances on the page will not have the same [id], and each [<label for>] correctly refers to ITS field;
- Line 29: The native field is linked to the [ref] from [defineModel].
3.2.3. The parent
| <!--
=========================================================================
08-component-events/src/App.vue - the parent listens to its children
-------------------------------------------------------------------------
Two ways for a child to pass information up:
1. emitting an event, which the parent listens to with @ (Counter);
2. v-model on a component (TextField): a shortcut for the prop
modelValue + the event update:modelValue.
=========================================================================
-->
<script setup lang="ts">
import { computed, ref } from 'vue';
import Counter from './components/Counter.vue';
import TextField from './components/TextField.vue';
const total = ref(0);
const message = ref('');
const firstName = ref('Marie');
const lastName = ref('PELISSIER');
const fullName = computed(() => `${firstName.value} ${lastName.value.toUpperCase()}`);
// the handler of the 'change' event: it receives the emitted data
function onChange(label: string, value: number): void {
total.value++;
message.value = `${label} is now ${value}`;
}
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Example 08 - events and v-model on a component</h1>
<div class="card p-3 mb-4">
<h2 class="h6">1. Events emitted by the children</h2>
<Counter label="Coffees" :max="3" @change="(v) => onChange('Coffees', v)" @limit="message = 'No more than 3 coffees!'" />
<Counter label="Glasses of water" :max="8" @change="(v) => onChange(`Glasses of water`, v)" @limit="message = 'Enough drinking.'" />
<p class="mb-0">Total clicks: {{ total }} - <em>{{ message }}</em></p>
</div>
<div class="card p-3">
<h2 class="h6">2. v-model on a component</h2>
<TextField v-model="firstName" label="First name" :max="20" help="Required" />
<TextField v-model="lastName" label="Last name" :max="20" help="Displayed in upper case" />
<p class="mb-0">Data of the parent: <strong>{{ fullName }}</strong></p>
<button class="btn btn-sm btn-outline-secondary mt-2 align-self-start" @click="firstName = ''; lastName = ''">Clear (from the parent)</button>
</div>
</div>
</template>
|
- Line 35: The parent listens for events from its children using [@], just like HTML events. The arrowed function [(v) => onChange('Coffees', v)] receives the event data; [@limit] executes a single instruction;
- line 42: [v-model] on a component;
- line 45: when the PARENT empties its data, the fields follow: the link works both ways.

3.3. Example 09: Templates and Slots
Props pass data; slots pass content: from HTML, other components. The [Card] component sets the frame (border, header, footer), and its parent determines what goes inside it:
| <!--
=========================================================================
09-slots/src/components/Card.vue - a reusable TEMPLATE
-------------------------------------------------------------------------
Props pass DATA; slots pass CONTENT
(HTML, other components). Card sets the frame (border, header,
footer), the parent provides what goes inside:
- <slot /> the default slot: the "main" content;
- <slot name="header"> a named slot, filled by <template #header>;
- the text placed BETWEEN <slot> and </slot> is displayed if the parent
provides nothing (default content).
It is the client-side equivalent of the layout.hbs template and its {{{body}}}
in the NestJS course.
=========================================================================
-->
<script setup lang="ts">
defineProps<{ color?: 'primary' | 'success' | 'danger' }>();
</script>
<template>
<div class="card mb-3" :class="color ? `border-${color}` : ''">
<div class="card-header" :class="color ? `text-bg-${color}` : ''">
<slot name="header">Untitled</slot>
</div>
<div class="card-body">
<slot />
</div>
<!-- $slots.footer: did the parent provide a footer? If not, no empty <div> -->
<div v-if="$slots.footer" class="card-footer text-muted small">
<slot name="footer" />
</div>
</div>
</template>
|
- line 23: a named slot; the text between [<slot>] and [</slot>] is displayed if the parent provides nothing;
- line 26: the default slot: the “main” content;
- line 29: [$slots] specifies which slots the parent has filled: without a footer, there is no empty [<div>].
This is the client-side equivalent of the [layout.hbs] template and its [{{{body}}}] in the [NestJS] course.
A second component, [DataTable], displays lines of any type:
| <!--
=========================================================================
09-slots/src/components/DataTable.vue - a GENERIC table, with a "scoped" slot
-------------------------------------------------------------------------
generic="T": a generic component, like a generic TypeScript
class - it displays rows of any type T.
The table knows how to iterate over the rows, but not how to display a
row: it PASSES each row to its slot (<slot name="row" :row="l">),
and it is the parent that says what to do with it (<template #row="{ row }">).
This is called a "scoped" slot: the content provided by the parent uses
data that belongs to the child.
=========================================================================
-->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ rows: T[]; columns: string[] }>();
</script>
<template>
<table class="table table-sm align-middle mb-0">
<thead>
<tr><th v-for="c in columns" :key="c">{{ c }}</th></tr>
</thead>
<tbody>
<tr v-for="(l, i) in rows" :key="l.id">
<slot name="row" :row="l" :index="i" />
</tr>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="text-muted"><slot name="empty">No data.</slot></td>
</tr>
</tbody>
</table>
</template>
|
- line 15: a generic component, such as a generic class [TypeScript]: [T] is the type of the lines, for which only one property is required: [id] (for the key of [v-for]);
- Line 26: The array knows how to iterate through the lines, but not how to display a line. It passes each line to its slot, and the parent displays it. This is referred to as a “scoped” slot: the content provided by the parent uses data that belongs to the child.
The parent:
| <!--
=========================================================================
09-slots/src/App.vue - using the Card and DataTable templates
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
import Card from './components/Card.vue';
import DataTable from './components/DataTable.vue';
interface Slot {
id: number;
time: string;
patient: string | null;
}
const slots = ref<Slot[]>([
{ id: 1, time: '8:00 - 8:20', patient: null },
{ id: 2, time: '8:20 - 8:40', patient: 'Mme Alice DURAND' },
{ id: 3, time: '8:40 - 9:00', patient: null },
]);
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Example 09 - templates and slots</h1>
<!-- #header is the short form of v-slot:header -->
<Card color="primary">
<template #header>Schedule of Mme PELISSIER</template>
<!-- the default slot: everything that is not in a <template #...> -->
<DataTable :rows="slots" :columns="['#', 'Slot', 'Status']">
<!-- we get the data passed by the DataTable: { row, index } -->
<template #row="{ row, index }">
<td>{{ index + 1 }}</td>
<td>{{ row.time }}</td>
<td>
<span v-if="row.patient" class="badge text-bg-secondary">{{ row.patient }}</span>
<span v-else class="badge text-bg-success">Free</span>
</td>
</template>
</DataTable>
<template #footer>{{ slots.filter((c) => c.patient === null).length }} free slot(s)</template>
</Card>
<Card color="success">
<template #header>A card without a footer</template>
<p class="mb-0">The footer is not provided: the card does not display it.</p>
</Card>
<Card>
<!-- no header provided: the default content of the slot, "Untitled" -->
<DataTable :rows="[]" :columns="['Name']">
<template #empty>Nobody for now.</template>
</DataTable>
</Card>
</div>
</template>
|
- line 30: [<template #header>] (short for [v-slot:header]) fills the slot named [header];
- line 32: anything not in a [<template #...>] goes into the default slot;
- line 34: the data passed through the array is retrieved. Thanks to the generic component, [TypeScript] knows that [row] is a [Slot];
- line 51: a card without a header: the default content, “Untitled,” is displayed.

3.4. Example 10: The Lifecycle of a Component
A component is created, displayed, updated, and then removed. [Vue.js] allows code to be executed at each of these stages. The [Clock] component:
| <!--
=========================================================================
10-lifecycle/src/components/Clock.vue
-------------------------------------------------------------------------
The lifecycle of a component:
1. setup : the code of <script setup> runs (creation);
2. onMounted : the component is DISPLAYED, its HTML elements exist;
3. onUpdated : its display has just been updated;
4. onUnmounted: the component has been REMOVED from the page.
A clock starts a timer (setInterval) when displayed... and must
absolutely stop it when it disappears: otherwise the timer
would keep running for nothing (memory leak).
=========================================================================
-->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
const emit = defineEmits<{ log: [message: string] }>();
const time = ref(new Date().toLocaleTimeString());
let timer: number | undefined;
emit('log', 'Clock: setup (creation)');
onMounted(() => {
emit('log', 'Clock: onMounted - starting the timer');
timer = window.setInterval(() => {
time.value = new Date().toLocaleTimeString();
}, 1000);
});
onUnmounted(() => {
window.clearInterval(timer);
emit('log', 'Clock: onUnmounted - timer stopped');
});
</script>
<template>
<div class="display-6 font-monospace">{{ time }}</div>
</template>
|
- line 23: the code for [<script setup>] runs once when the component is created;
- lines 25–30: [onMounted]: the component is displayed, and its HTML elements exist. The timer is started;
- lines 32–35: [onUnmounted]: the component has been removed from the page. The timer must be stopped; otherwise, it would continue to run for a component that no longer exists (a “memory leak”).
The parent:
| <!--
=========================================================================
10-lifecycle/src/App.vue
-------------------------------------------------------------------------
- v-if on <Clock>: checking / unchecking the box creates / destroys the
component (onMounted / onUnmounted, see Clock.vue);
- a "template ref": ref="field" gives access to the HTML <input> element
itself, once the component is displayed (to give it the focus);
- watch with { immediate: true }: executed right away, then on each
change;
- nextTick: waiting for Vue to have updated the page.
=========================================================================
-->
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import Clock from './components/Clock.vue';
const withClock = ref(true);
const log = ref<string[]>([]);
const search = ref('');
const showSearch = ref(false);
// the <input ref="field"> element of the template (null as long as it is not displayed)
const field = useTemplateRef<HTMLInputElement>('field');
function trace(message: string): void {
log.value.push(`${new Date().toLocaleTimeString()} ${message}`);
}
onMounted(() => trace('App: onMounted'));
watch(
search,
(value) => {
document.title = value ? `Search: ${value}` : 'Example 10';
},
{ immediate: true },
);
async function openSearch(): Promise<void> {
showSearch.value = true;
// at this moment, the <input> does not exist yet: Vue will update the page
// a little later. nextTick() waits for this update.
await nextTick();
field.value?.focus();
}
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Example 10 - the lifecycle of a component</h1>
<div class="card p-3 mb-3">
<label class="form-check-label mb-2">
<input v-model="withClock" type="checkbox" class="form-check-input"> show the clock
</label>
<Clock v-if="withClock" @log="trace" />
</div>
<div class="card p-3 mb-3">
<button v-if="!showSearch" class="btn btn-outline-primary align-self-start" @click="openSearch">
Search...
</button>
<input v-else ref="field" v-model="search" class="form-control" placeholder="the tab title follows what you type">
</div>
<h2 class="h6">Log</h2>
<ol class="small">
<li v-for="(line, i) in log" :key="i">{{ line }}</li>
</ol>
</div>
</template>
|
- Line 57: Checking and unchecking the box creates and destroys the clock: the log shows [setup], [onMounted], [onUnmounted];
- Line 24: A template reference: The [ref="field"] attribute of the [<input>] element provides access to the HTML element itself. It is equal to [null] as long as the element is not displayed;
- lines 32–37: [watch] with [{ immediate: true }] runs immediately, then on every change: the tab title updates as text is entered;
- lines 40–45: After [showSearch.value = true], the field does not yet exist: [Vue.js] batches the page updates and performs them a little later. [await nextTick()] waits for this update; only then can focus be given to the field.

3.5. Example 11: Composables, provide, and inject
How can logic be shared between components? On the server side, [NestJS] had its services injected into the controllers. [Vue.js] has its composables: simple functions, whose names begin with [use], that combine reactive state and the accompanying code.
| // =========================================================================
// 11-composables/src/composables/useClock.ts - a COMPOSABLE
// -------------------------------------------------------------------------
// A composable is a simple function, whose name starts with "use"
// by convention, which groups a reactive state AND the logic that goes with it
// (here: the timer, its start and its stop). Every component that
// calls it gets ITS own clock, without copying the code.
//
// It is the Vue.js way of sharing logic between components - the
// role held, on the server side, by the NestJS services.
// =========================================================================
import { onMounted, onUnmounted, ref, type Ref } from 'vue';
export function useClock(periodMs = 1000): { now: Ref<Date> } {
const now = ref(new Date());
let timer: number | undefined;
// the lifecycle hooks attach to the component that CALLS the composable
onMounted(() => {
timer = window.setInterval(() => (now.value = new Date()), periodMs);
});
onUnmounted(() => window.clearInterval(timer));
return { now };
}
|
- line 15: the function returns an object containing [ref] [now];
- lines 20–22: The lifecycle functions called within a composable are attached to the component that calls the composable: each component has its own timer, which stops when the component is destroyed.
A second composable, without a lifecycle:
| // =========================================================================
// 11-composables/src/composables/useCart.ts
// -------------------------------------------------------------------------
// A second composable, WITHOUT lifecycle: a cart (list of items,
// total, adding, removing). Each call of useCart() creates a NEW
// cart: two components that call it each have their own.
// (For a cart SHARED by the whole application, see provide / inject
// below, and Pinia in example 15.)
// =========================================================================
import { computed, ref } from 'vue';
export interface Item {
label: string;
price: number;
}
export function useCart() {
const items = ref<Item[]>([]);
const total = computed(() => items.value.reduce((sum, a) => sum + a.price, 0));
function add(item: Item): void {
items.value.push(item);
}
function remove(index: number): void {
items.value.splice(index, 1);
}
return { items, total, add, remove };
}
// the type of what useCart returns: useful for provide / inject
export type Cart = ReturnType<typeof useCart>;
|
- line 19: each call to [useCart()] creates a NEW shopping cart;
- line 33: the type of what [useCart] returns, inferred by [TypeScript].
To allow multiple components to share the SAME shopping cart, we use [provide] and [inject], a form of dependency injection:
| // =========================================================================
// 11-composables/src/keys.ts - the keys of provide / inject
// -------------------------------------------------------------------------
// provide(key, value): a component MAKES AVAILABLE a value for
// all its descendants, whatever their depth;
// inject(key): a descendant retrieves it - without the intermediate
// components having to pass it from props to props.
// It is a form of dependency injection, as in NestJS.
//
// An InjectionKey<T> (a typed Symbol) guarantees that inject() returns a
// value of the right type.
// =========================================================================
import type { InjectionKey } from 'vue';
import type { Cart } from './composables/useCart';
export const CART_KEY: InjectionKey<Cart> = Symbol('cart');
|
- Line 17: the key under which the shopping cart is provided: a [Symbol] (unique value) typed by [InjectionKey<Cart>], so that [inject] returns an object of the correct type.
The root component creates the shopping cart and provides it:
| <!--
=========================================================================
11-composables/src/App.vue
-------------------------------------------------------------------------
- useClock(): App has its clock (1 s), CartSummary its own (5 s);
- useCart() + provide: App creates ONE cart and provides it to all its
descendants; Catalog and CartSummary inject it and therefore
share the same one.
=========================================================================
-->
<script setup lang="ts">
import { provide } from 'vue';
import { useClock } from './composables/useClock';
import { useCart } from './composables/useCart';
import { CART_KEY } from './keys';
import Catalog from './components/Catalog.vue';
import CartSummary from './components/CartSummary.vue';
const { now } = useClock();
provide(CART_KEY, useCart());
</script>
<template>
<div class="container py-4" style="max-width: 720px">
<h1 class="h3 mb-3">Example 11 - composables and provide / inject</h1>
<p class="text-muted">Clock of App: {{ now.toLocaleTimeString() }}</p>
<div class="row g-3">
<div class="col-md-6"><Catalog /></div>
<div class="col-md-6"><CartSummary /></div>
</div>
</div>
</template>
|
- line 19: App has its own clock, refreshed every second;
- line 20: The app makes a shopping cart available to ALL its descendants, regardless of their depth, without having to pass it from props to props.
Two descendants inject it:
| <!--
11-composables/src/components/Catalog.vue - a descendant that INJECTS the cart
-->
<script setup lang="ts">
import { inject } from 'vue';
import { CART_KEY } from '../keys';
import type { Item } from '../composables/useCart';
// the cart provided by App.vue (provide); explicit error if it is missing
const cart = inject(CART_KEY);
if (!cart) {
throw new Error('Catalog must be placed under a component that provides the cart');
}
const catalog: Item[] = [
{ label: 'Consultation', price: 30 },
{ label: 'Vaccine', price: 12.5 },
{ label: 'Certificate', price: 5 },
];
</script>
<template>
<ul class="list-group">
<li v-for="a in catalog" :key="a.label" class="list-group-item d-flex justify-content-between align-items-center">
{{ a.label }} (€{{ a.price }})
<button class="btn btn-sm btn-outline-primary" @click="cart.add(a)">Add</button>
</li>
</ul>
</template>
|
- line 10: The basket provided by an ancestor is retrieved; [inject] returns [undefined] if no ancestor has provided it: this is indicated by an explicit error.
| <!--
11-composables/src/components/CartSummary.vue - another descendant, the SAME cart
-->
<script setup lang="ts">
import { inject } from 'vue';
import { CART_KEY } from '../keys';
import { useClock } from '../composables/useClock';
const cart = inject(CART_KEY)!;
// this component has ITS own clock, refreshed every 5 seconds
const { now } = useClock(5000);
</script>
<!-- cart.items.value: in a template, Vue only "unwraps" (implicit
.value) the TOP-LEVEL refs of the script, like now.
The refs stored INSIDE an object (cart.items) are written with .value -->
<template>
<div class="card p-3">
<h2 class="h6">Cart ({{ cart.items.value.length }} item(s)) - {{ now.toLocaleTimeString() }}</h2>
<ul class="mb-2">
<li v-for="(a, i) in cart.items.value" :key="i">
{{ a.label }} <a href="#" @click.prevent="cart.remove(i)">remove</a>
</li>
</ul>
<strong>Total: €{{ cart.total.value }}</strong>
</div>
</template>
|
- Line 9: Same injection; [!] confirms to [TypeScript] that the value exists;
- line 11: this component has its OWN clock, refreshed every 5 seconds;
- line 19: in a template, [Vue.js] automatically “unpacks” only the top-level [ref] elements of the script. A [ref] stored INSIDE an object ([cart.items]) is written with [.value].
Both components display the same shopping cart; the two clocks run at their own pace:

3.6. Example 12: A Reusable Confirmation Window
Before deleting an item, the system asks, “Are you sure?” The browser function [window.confirm()] exists, but its dialog box cannot be formatted, nor can its buttons be translated. We replace it with a [Bootstrap] window, controlled by a composable. This is the [Vue.js] version of Example 21 from the [NestJS] course.
| // =========================================================================
// 12-confirmation/src/composables/useConfirmation.ts
// -------------------------------------------------------------------------
// "Are you sure?": window.confirm() exists, but it can neither be
// styled, nor can its buttons be translated. We replace it with a Bootstrap
// dialog (ConfirmDialog.vue), driven by this composable.
//
// The trick: confirm(message) returns a PROMISE, resolved to true (Yes)
// or false (No) when the user answers. The calling code is then written
// as simply as with window.confirm:
//
// if (await confirm('Delete this doctor?')) { ... }
//
// The state (displayed message, function to call on the answer) is declared
// OUTSIDE the function: it is therefore UNIQUE for the whole application. A
// single dialog, placed once in App.vue, serves all the components.
// =========================================================================
import { readonly, ref } from 'vue';
const message = ref<string | null>(null); // null: dialog closed
let answer: ((yes: boolean) => void) | null = null;
export function useConfirmation() {
function confirm(text: string): Promise<boolean> {
message.value = text;
return new Promise<boolean>((resolve) => {
answer = resolve;
});
}
function respond(yes: boolean): void {
message.value = null;
answer?.(yes);
answer = null;
}
// readonly: the components read the message, only the composable modifies it
return { message: readonly(message), confirm, respond };
}
|
- Lines 21 and 22: The state is declared OUTSIDE the function [useConfirmation] at the module level. It is therefore unique across the entire application: a single window serves all components;
- Lines 25–30: [confirm] opens the window (by assigning a value to the message) and returns a promise. It stores the function [resolve] from this promise in [answer]: the promise will not be resolved until the user has responded;
- lines 32–35: The user’s response closes the window and resolves the promise to [true] or [false];
- line 39: components can read the message, but not modify it.
The window:
| <!--
=========================================================================
12-confirmation/src/components/ConfirmDialog.vue
-------------------------------------------------------------------------
The modal dialog, placed ONCE in App.vue.
- <Teleport to="body">: its HTML is inserted at the end of <body>, outside
the component tree - a modal dialog must go
above everything, wherever it is declared;
- same Bootstrap classes as a real modal (.modal, .modal-backdrop),
displayed "hard-coded" (show d-block): no need for Bootstrap's JavaScript;
- keyboard: Escape = No; the focus is placed on the "No" button (the
least dangerous choice) on opening.
=========================================================================
-->
<script setup lang="ts">
import { nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
import { useConfirmation } from '../composables/useConfirmation';
const { message, respond } = useConfirmation();
const noButton = useTemplateRef<HTMLButtonElement>('noButton');
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
respond(false);
}
}
// on opening: focus on "No", listening to the Escape key; on closing: we stop listening
watch(message, async (value) => {
if (value !== null) {
document.addEventListener('keydown', onKeydown);
await nextTick();
noButton.value?.focus();
} else {
document.removeEventListener('keydown', onKeydown);
}
});
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body">
<template v-if="message !== null">
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true" @click.self="respond(false)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmation</h5>
<button type="button" class="btn-close" aria-label="Close" @click="respond(false)"></button>
</div>
<div class="modal-body">
<p class="mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button ref="noButton" type="button" class="btn btn-secondary" @click="respond(false)">No, go back</button>
<button type="button" class="btn btn-danger" @click="respond(true)">Yes, confirm</button>
</div>
</div>
</div>
</div>
</template>
</Teleport>
</template>
|
- line 42: the window’s HTML is inserted at the end of [<body>], outside the component tree: a modal window must take precedence over everything else, regardless of where it is declared;
- line 45: the [Bootstrap] classes of a modal window, displayed “hard-coded” ([show d-block]): no need for the [JavaScript] from [Bootstrap];
- line 45: the modifier [.self]: only a click on the element itself (the shaded area), and not on the window containing it, triggers the “No” response;
- Lines 29–37: Upon opening, the program listens for the Esc key and sets the focus to the “No” button (the safest option: an accidental press of Enter won’t delete anything); upon closing, the program stops listening for keyboard input.
The component that uses it:
| <!--
=========================================================================
12-confirmation/src/App.vue - using the confirmation dialog
-------------------------------------------------------------------------
Same page as example 21 of the NestJS course (links, buttons, confirmation),
but without any round trip to a server.
=========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
import { useConfirmation } from './composables/useConfirmation';
interface Contact {
id: number;
name: string;
}
const contacts = ref<Contact[]>([
{ id: 1, name: 'Jean MARTIN' },
{ id: 2, name: 'Alice DURAND' },
{ id: 3, name: 'Luc GERMAN' },
]);
const message = ref('');
const { confirm } = useConfirmation();
// an async function: it "waits" for the user's answer
async function remove(c: Contact): Promise<void> {
if (!(await confirm(`Delete the contact ${c.name}?`))) {
message.value = 'Deletion cancelled.';
return;
}
contacts.value = contacts.value.filter((x) => x.id !== c.id);
message.value = `${c.name} has been deleted.`;
}
async function removeAll(): Promise<void> {
if (await confirm(`Delete the ${contacts.value.length} contacts?`)) {
contacts.value = [];
message.value = 'All the contacts have been deleted.';
}
}
</script>
<template>
<div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Example 12 - a confirmation dialog</h1>
<ul class="list-group mb-3">
<li v-for="c in contacts" :key="c.id" class="list-group-item d-flex justify-content-between align-items-center">
{{ c.name }}
<button class="btn btn-sm btn-outline-danger" @click="remove(c)">Delete</button>
</li>
<li v-if="contacts.length === 0" class="list-group-item text-muted">No contacts.</li>
</ul>
<button class="btn btn-danger" :disabled="contacts.length === 0" @click="removeAll">Delete all</button>
<p v-if="message" class="alert alert-info mt-3">{{ message }}</p>
</div>
<!-- the dialog, ONCE for the whole application -->
<ConfirmDialog />
</template>
|
- line 29: thanks to the promise, the code is as simple to write as with [window.confirm]: the [async] function “waits” for the user’s response;
- line 59: the window, created ONCE for the entire application.

After “Yes, confirm”:
