Skip to content

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:

1
2
3
4
5
6
7
8
9
// 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.

Image

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.

Image

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.

Image

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.

Image

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:

Image

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.

Image

After “Yes, confirm”:

Image