Skip to content

3. The Components

3.1. Example 10: Components and Their Inputs

A component is a reusable piece of the interface. This example displays doctors’ profiles: a component named [DoctorCard] (the child), used multiple times by the component [App] (the parent).

A component’s files are usually stored in a folder named after the component:

1
2
3
4
5
6
7
8
10-components/src/app/
├── doctor-card/
│   ├── doctor-card.ts     the class of the component
│   ├── doctor-card.html   its template
│   └── doctor-card.css    its style
├── doctor.ts             the Doctor type
├── app.ts
└── app.html

The data type shared by the parent and child:

1
2
3
4
5
6
7
8
9
// the data type shared by the parent (App) and the child (DoctorCard)
export interface Doctor {
  id: number;
  title: string;
  firstName: string;
  lastName: string;
  specialty: string;
  available: boolean;
}

3.1.1. The child

// =========================================================================
// 10-components/src/app/doctor-card/doctor-card.ts - a CHILD component
// -------------------------------------------------------------------------
// It receives its data from its parent through INPUTS, declared
// with input(): each input is a read-only SIGNAL, which follows the
// value passed by the parent. Their type is checked at compile time:
// <app-doctor-card [doctor]="42"> would be rejected.
//
// Golden rule: a component NEVER MODIFIES its inputs. The data
// goes down from the parent to the child; to request a change,
// the child emits an event (see example 11).
// =========================================================================

import { booleanAttribute, Component, computed, input } from '@angular/core';
import { Doctor } from '../doctor';

@Component({
  // the selector: the tag that represents this component in its parent
  selector: 'app-doctor-card',
  templateUrl: './doctor-card.html',
  styleUrl: './doctor-card.css',
})
export class DoctorCard {
  // input.required: the parent MUST provide this input
  readonly doctor = input.required<Doctor>();
  readonly rank = input.required<number>();
  // input(default value): an optional input. booleanAttribute
  // allows simply writing <app-doctor-card compact> for true
  readonly compact = input(false, { transform: booleanAttribute });

  // a computed based on an input: recomputed if the parent changes it
  protected readonly initials = computed(() => `${this.doctor().firstName[0]}${this.doctor().lastName[0]}`);
}
  • Line 19: the selector: the parent will write [<app-doctor-card>];
  • lines 25–29: the component’s inputs ([inputs])—the data passed to it by its parent. [input()] creates a read-only signal that tracks the value passed by the parent:
    • lines 25–26: [input.required<T>()]: a required input. Omitting [[doctor]] from the parent’s template results in a compilation error;
    • Line 29: [input(default value)]: an optional input. The [transform] option converts the received value: [booleanAttribute] checks for the presence of the attribute ([<app-doctor-card compact>], with no value) into a [true], as with the attribute HTML [disabled];
  • line 32: a [computed] based on an input: recalculated if the parent changes the input.

The inputs are not [protected]: they are part of the component’s public interface, the one its parent uses.

Golden rule: a component never modifies its inputs (which are read-only, anyway). Data flows from the parent to the child; to request a change, the child emits an event (Example 11).

<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>
      <!-- the inputs are signals: they are read by calling them -->
      <h2 class="h6 mb-1">{{ rank() }}. {{ doctor().title }} {{ doctor().firstName }} {{ doctor().lastName }}</h2>
      @if (!compact()) {
        <p class="mb-0 text-muted small">{{ doctor().specialty }}</p>
      }
    </div>
  </div>
</div>
  • lines 1, 6, 8: the inputs are signals: they are read by calling them;
  • lines 7–9: the specialty is displayed only in the normal version of the card.
/* the style of the component: it only applies to ITS elements */
.initials {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #dd0031;
  color: white;
  font-weight: bold;
}

The class [initials] applies only to this component: another component could have its own class, [initials], without any conflict.

3.1.2. The parent

// =========================================================================
// 10-components/src/app/app.ts - the PARENT component
// -------------------------------------------------------------------------
// It imports the DoctorCard component (imports array) and uses it as
// a new HTML tag, <app-doctor-card>, as many times as
// needed: a component is a REUSABLE piece of interface, with its
// own data, its template and its style.
// =========================================================================

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DoctorCard } from './doctor-card/doctor-card';
import { Doctor } from './doctor';

@Component({
  selector: 'app-root',
  imports: [FormsModule, DoctorCard],
  templateUrl: './app.html',
})
export class App {
  protected readonly doctors = signal<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 },
  ]);
  protected readonly compact = signal(false);

  // the parent modifies ITS data: the children concerned are updated
  protected toggleAvailability(): void {
    this.doctors.update((list) => list.map((m) => ({ ...m, available: !m.available })));
  }
}
  • line 17: the parent imports the child component; without this, [<app-doctor-card>] would be an unknown tag, and the compilation would fail;
  • lines 29–31: the parent modifies ITS data. It replaces each doctor with a modified copy: the [m] object passed to each card changes, and the cards update.
<div class="container py-4">
  <h1 class="h3 mb-3">Example 10 - components and their inputs</h1>
  <div class="d-flex gap-3 mb-3">
    <label class="form-check-label">
      <input type="checkbox" class="form-check-input" [(ngModel)]="compact"> compact display
    </label>
    <button class="btn btn-sm btn-outline-primary" (click)="toggleAvailability()">Toggle availability</button>
  </div>
  <div class="row g-3">
    @for (m of doctors(); track m.id; let i = $index) {
      <div class="col-md-4">
        <!-- [doctor]="m" passes the OBJECT m (an expression);
             doctor="m", without brackets, would pass the STRING "m" -->
        <app-doctor-card [doctor]="m" [rank]="i + 1" [compact]="compact()" />
      </div>
    }
  </div>
  <p class="mt-3 mb-1 small text-muted">The same card, in compact version, through an attribute without a value:</p>
  <div class="w-50">
    <app-doctor-card [doctor]="doctors()[0]" [rank]="1" compact />
  </div>
</div>
  • line 14: the child is used as a HTML tag. [[doctor]="m"] is a property binding to the input [doctor]: it transmits the OBJECT [m]. Without square brackets, [doctor="m"] would pass the STRING ["m"], which the compiler would reject. A component with no content can be written using a self-closing tag ([/>]);
  • line 20: [compact], with no value: thanks to [booleanAttribute], the input receives [true].

[npm start 10-components]; click “Toggle availability”:

Image

3.2. Example 11: A component’s outputs and model()

A child has two ways to send information back to its parent:

  1. emit an event (an output, [output]), which the parent listens for as an event HTML; 2. a bidirectional connection, [[( )]], on a [model()].

3.2.1. Sending an Event

A counter that notifies its parent with every click and when it reaches its limit:

// =========================================================================
// 11-outputs-model/src/app/counter/counter.ts
// -------------------------------------------------------------------------
// A child that NOTIFIES its parent: output() declares an event that the
// component can emit, with the type of its data. The parent listens to it
// like an HTML event: <app-counter (incremented)="..." (limit)="...">.
// =========================================================================

import { Component, input, output, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  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>
  `,
})
export class Counter {
  readonly label = input.required<string>();
  readonly max = input.required<number>();

  // two events: incremented (with the new value) and limit (without data)
  readonly incremented = output<number>();
  readonly limit = output<void>();

  protected readonly value = signal(0);

  protected plus(): void {
    if (this.value() >= this.max()) {
      this.limit.emit();
      return;
    }
    this.value.update((v) => v + 1);
    this.incremented.emit(this.value());
  }
}
  • lines 13–19: the component’s template, written in the decorator (property [template], enclosed in grave accents);
  • lines 26–27: [output<T>()] declares an output: an event that the component can emit, along with the type of its data. [limit] has no data ([void]);
  • lines 33 and 37: [emit(data)] emits the event; the parent receives the data.

Avoid naming an output after a browser event ([change], [click]...): browser events emitted by the component’s elements propagate up to its host element, and the parent would also receive them.

3.2.2. A two-way binding

A "custom" form field (label, field, tooltip, character counter) that links to parent data such as [ngModel]:

// =========================================================================
// 11-outputs-model/src/app/text-field/text-field.ts
// -------------------------------------------------------------------------
// A "home-made" form field (label + field + help + character
// counter) that accepts a TWO-way binding, like ngModel:
//     <app-text-field [(value)]="lastName" label="Last name" [max]="20" />
//
// model() creates a WRITABLE signal linked to the parent: READING it gives the
// parent's value, WRITING it (set) emits the valueChange event, which the
// [( )] syntax turns into an update of the parent's signal. The data
// thus really remains the property of the parent.
// =========================================================================

import { Component, input, model } from '@angular/core';

// a counter shared by all the instances: a unique identifier per
// field, to link <label for> and <input id>
let nextNumber = 0;

@Component({
  selector: 'app-text-field',
  template: `
    <div class="mb-3">
      <label class="form-label" [for]="id">{{ label() }}</label>
      <!-- [value]: the value of the parent; (input): each keystroke replaces it -->
      <input class="form-control" [id]="id" [value]="value()" [attr.maxlength]="max()"
             [class.is-invalid]="value().length === max()" (input)="onInput($event)">
      <div class="form-text">{{ help() }} ({{ value().length }} / {{ max() }})</div>
    </div>
  `,
})
export class TextField {
  readonly value = model.required<string>();
  readonly label = input.required<string>();
  readonly max = input.required<number>();
  readonly help = input('');

  protected readonly id = `text-field-${++nextNumber}`;

  protected onInput(event: Event): void {
    this.value.set((event.target as HTMLInputElement).value);
  }
}
  • Line 33: [model()] creates a writable signal linked to the parent. READING it returns the parent’s value; WRITING it ([set], line 41) changes the value AND emits the event [valueChange] (the model name followed by [Change]). The parent that writes [[(value)]="signal"] thus has its signal updated;
  • line 18: a module variable (outside the class) is shared by all instances of the component: a counter that assigns a unique identifier to each field (line 38), to link [<label for>] and [<input id>];
  • lines 26–27: [[value]] displays the parent’s value; [(input)] replaces it with each keystroke (line 41). [[attr.maxlength]] is an attribute binding to HTML (the attribute [maxlength]), not a property binding.

3.2.3. The parent

// =========================================================================
// 11-outputs-model/src/app/app.ts - the parent listens to its children
// -------------------------------------------------------------------------
// Two ways for a child to pass information up:
//   1. emitting an event (output), which the parent listens to with ( );
//   2. a two-way binding [( )] on a model(): a shortcut
//      for the input [value] + the event (valueChange).
// =========================================================================

import { Component, computed, signal } from '@angular/core';
import { Counter } from './counter/counter';
import { TextField } from './text-field/text-field';

@Component({
  selector: 'app-root',
  imports: [Counter, TextField],
  templateUrl: './app.html',
})
export class App {
  protected readonly total = signal(0);
  protected readonly message = signal('');
  protected readonly firstName = signal('Marie');
  protected readonly lastName = signal('PELISSIER');
  protected readonly fullName = computed(() => `${this.firstName()} ${this.lastName().toUpperCase()}`);

  // the handler of the incremented event: it receives the emitted data ($event)
  protected onChange(label: string, value: number): void {
    this.total.update((t) => t + 1);
    this.message.set(`${label} is now ${value}`);
  }

  protected clear(): void {
    this.firstName.set('');
    this.lastName.set('');
  }
}
<div class="container py-4" style="max-width: 640px">
  <h1 class="h3 mb-3">Example 11 - the outputs of a component and model()</h1>

  <div class="card p-3 mb-4">
    <h2 class="h6">1. Events emitted by the children</h2>
    <!-- $event: the data emitted by the child (here, the new value) -->
    <app-counter label="Coffees" [max]="3" (incremented)="onChange('Coffees', $event)"
                  (limit)="message.set('No more than 3 coffees!')" />
    <app-counter label="Glasses of water" [max]="8" (incremented)="onChange('Glasses of water', $event)"
                  (limit)="message.set('Enough drinking.')" />
    <p class="mb-0">Total clicks: {{ total() }} - <em>{{ message() }}</em></p>
  </div>

  <div class="card p-3">
    <h2 class="h6">2. A two-way binding with model()</h2>
    <!-- [(value)]="firstName": the firstName signal of the parent, read AND modified by the child -->
    <app-text-field [(value)]="firstName" label="First name" [max]="20" help="Required" />
    <app-text-field [(value)]="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)="clear()">Clear (from the parent)</button>
  </div>
</div>
  • Line 7: [(incremented)="..."] listens to the output [incremented] from the counter; [$event] is the data output (the new value). [label="Coffees"], without square brackets: a string-type input can receive a constant value;
  • line 8: [(limit)]: a method of a signal ([set]) can be called directly within the template;
  • line 9: a second counter, with its own label, limit and messages;
  • line 17: [[(value)]="firstName"]: the parent’s signal [firstName] (without parentheses: the signal itself is specified), read AND modified by the child. This is the abbreviation for [[value]="firstName()" (valueChange)="firstName.set($event)"];
  • line 20: the parent modifies its data: the two fields follow.

[npm start 11-outputs-model]; four clicks on the coffees, one on the water, then type a name:

Image

3.3. Example 12: Content Projection

Inputs transmit data; content projection transmits content (from HTML and other components), written by the parent BETWEEN the component’s tags. This is the client-side equivalent of the [layout.hbs] template for the [NestJS] course and its [{{{body}}}].

3.3.1. A card

A reusable frame (border, header, footer), whose parent provides the content:

// =========================================================================
// 12-projection/src/app/card/card.ts - a reusable TEMPLATE
// -------------------------------------------------------------------------
// The inputs pass DATA; content PROJECTION passes
// CONTENT (HTML, other components), written by the parent BETWEEN the
// <app-card> and </app-card> tags. The card sets the frame (border,
// header, footer), the parent provides what goes inside:
//   - <ng-content />                   : the "main" content;
//   - <ng-content select="[header]" /> : only the elements that have
//                                        the header attribute;
//   - the content placed BETWEEN <ng-content> and </ng-content> 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.
// =========================================================================

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-card',
  template: `
    <div class="card mb-3" [class]="color() ? 'border-' + color() : ''">
      <div class="card-header" [class]="color() ? 'text-bg-' + color() : ''">
        <ng-content select="[header]">Untitled</ng-content>
      </div>
      <div class="card-body">
        <ng-content />
      </div>
      <!-- an empty footer (the parent provided nothing) is hidden by the CSS :empty -->
      <div class="card-footer text-muted small"><ng-content select="[footer]" /></div>
    </div>
  `,
  styles: `
    .card-footer:empty {
      display: none;
    }
  `,
})
export class Card {
  readonly color = input<'primary' | 'success' | 'danger'>();
}
  • line 27: [<ng-content />]: the location where [Angular] places the content written by the parent between [<app-card>] and [</app-card>];
  • line 24: [<ng-content select="[header]">]: only those content elements that have the [header] attribute. [select] accepts a CSS selector (a tag, a class, or an attribute). The text between [<ng-content>] and [</ng-content>] is displayed if the parent element does not provide any content: this is the default content;
  • line 30: the footer: elements that have the [footer] attribute;
  • lines 33–37: an empty footer (the parent element provided nothing) is hidden: the pseudo-class CSS [:empty] denotes an element with no content;
  • Line 40: An optional input that can take only three values; if no default value is specified, it defaults to [undefined].

3.3.2. A generic array

An array that knows how to iterate through rows but doesn’t know how to display ONE row: its parent tells it how by passing it a row template.

// =========================================================================
// 12-projection/src/app/data-table/data-table.ts - a GENERIC table
// -------------------------------------------------------------------------
// DataTable<T> is a generic class: it displays rows of any
// type T. It knows how to iterate over the rows, but not how to display ONE
// row: the parent tells it, by passing it a row TEMPLATE
// (an <ng-template #row>, a piece of HTML that is not displayed as
// is, but that can be displayed on demand, as many times as needed).
//
// contentChild('row'): the <ng-template #row> placed by the parent between
// <app-data-table> and </app-data-table>. The table displays it for each row
// with ngTemplateOutlet, passing it a CONTEXT: the row ($implicit)
// and its rank (index). The content provided by the parent thus uses
// data that belongs to the child.
// =========================================================================

import { Component, contentChild, input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

// what the table passes to the template of each row
export interface RowContext<T> {
  $implicit: T; // the value retrieved by let-row
  index: number; // retrieved by let-i="index"
}

@Component({
  selector: 'app-data-table',
  imports: [NgTemplateOutlet],
  template: `
    <table class="table table-sm align-middle mb-0">
      <thead>
        <tr>
          @for (c of columns(); track c) {
            <th>{{ c }}</th>
          }
        </tr>
      </thead>
      <tbody>
        @for (l of rows(); track l.id; let i = $index) {
          <tr>
            <ng-container [ngTemplateOutlet]="rowTemplate() ?? null" [ngTemplateOutletContext]="{ $implicit: l, index: i }" />
          </tr>
        } @empty {
          <tr>
            <td class="text-muted" [attr.colspan]="columns().length">
              <ng-content select="[empty]">No data.</ng-content>
            </td>
          </tr>
        }
      </tbody>
    </table>
  `,
})
export class DataTable<T extends { id: number }> {
  readonly rows = input.required<T[]>();
  readonly columns = input.required<string[]>();
  protected readonly rowTemplate = contentChild<TemplateRef<RowContext<T>>>('row');
}
  • line 54: [DataTable<T>] is a generic class: it displays lines of any type [T], provided they have a [id] (for [track], line 39);
  • Line 57: [contentChild('row')]: a signal that refers to the element of the projected content marked [#row]. This will be a [<ng-template>]: a portion of HTML that is not displayed as-is, but can be displayed on demand, as many times as necessary (a [TemplateRef]);
  • line 41: the [ngTemplateOutlet] directive displays a template by passing it a context—an object whose properties become variables in the template. [$implicit] is the default value (lines 21–24);
  • lines 43–49: if the list is empty, the block [@empty], with projected content ([[empty]]) and default content;
  • line 45: [[attr.colspan]]: an attribute binding, since [colspan] is not a property of the [<td>] element.

3.3.3. The parent

// =========================================================================
// 12-projection/src/app/app.ts - using the Card and DataTable templates
// =========================================================================

import { Component, computed, signal } from '@angular/core';
import { Card } from './card/card';
import { DataTable } from './data-table/data-table';

interface Slot {
  id: number;
  time: string;
  patient: string | null;
}

@Component({
  selector: 'app-root',
  imports: [Card, DataTable],
  templateUrl: './app.html',
})
export class App {
  protected readonly slots = signal<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 },
  ]);
  protected readonly free = computed(() => this.slots().filter((c) => c.patient === null).length);
  protected readonly nobody: { id: number; name: string }[] = [];
}
<div class="container py-4" style="max-width: 720px">
  <h1 class="h3 mb-3">Example 12 - content projection</h1>

  <app-card color="primary">
    <!-- the header attribute: this <span> will go into the <ng-content select="[header]"> -->
    <span header>Schedule of Mme PELISSIER</span>
    <!-- everything that has neither header nor footer goes into the main <ng-content /> -->
    <app-data-table [rows]="slots()" [columns]="['#', 'Slot', 'Status']">
      <!-- the template of a row: let-c receives the row, let-i its rank -->
      <ng-template #row let-c let-i="index">
        <td>{{ i + 1 }}</td>
        <td>{{ c.time }}</td>
        <td>
          @if (c.patient) {
            <span class="badge text-bg-secondary">{{ c.patient }}</span>
          } @else {
            <span class="badge text-bg-success">Free</span>
          }
        </td>
      </ng-template>
    </app-data-table>
    <span footer>{{ free() }} free slot(s)</span>
  </app-card>

  <app-card color="success">
    <span header>A card without a footer</span>
    <p class="mb-0">The footer is not provided: the card does not display it.</p>
  </app-card>

  <app-card>
    <!-- no header provided: the default content, "Untitled" -->
    <app-data-table [rows]="nobody" [columns]="['Name']">
      <span empty>Nobody for now.</span>
    </app-data-table>
  </app-card>
</div>
  • lines 4–23: a blue card. Its content is distributed by the card: [<span header>] (line 6) goes into the header, [<span footer>] (line 22) into the footer, and the rest (the table) into the body;
  • lines 10–20: the template for a line. [let-c] retrieves the value [$implicit] from the context (the line), and [let-i="index"] retrieves its property [index]. The content provided by the parent thus uses data that belongs to the child;
  • lines 25–28: a card without a footer;
  • lines 30–35: a card without a header (the default content, “Untitled”), and an empty table.

[npm start 12-projection]:

Image

The compiler does not recognize the type of [c] in the template (lines 10–20): it treats it as [any]. This can be remedied (with a directive declaring the context type), at the cost of a little more code; this is the limitation of this technique.

3.4. Example 13: The Lifecycle of a Component

A component is created, displayed, exists, and then disappears. [Angular] allows you to take action at each stage: this is the component’s lifecycle.

3.4.1. A clock

A clock starts a timer when it is created... and must stop it when it disappears: otherwise, the timer would continue to run for no reason (memory leak).

// =========================================================================
// 13-lifecycle/src/app/clock/clock.ts
// -------------------------------------------------------------------------
// The lifecycle of a component:
//   1. constructor      : the class is created; the inputs are not
//                         available yet;
//   2. ngOnInit         : the inputs have received their first value;
//   3. afterNextRender  : the component is DISPLAYED, its HTML elements
//                         exist in the page;
//   4. ngOnDestroy      : the component is about to be REMOVED from the page.
// A clock starts a timer (setInterval) at startup... and must
// absolutely stop it when it disappears: otherwise the timer would
// keep running for nothing (memory leak).
// =========================================================================

import { afterNextRender, Component, OnDestroy, OnInit, output, signal } from '@angular/core';

@Component({
  selector: 'app-clock',
  template: `<div class="display-6 font-monospace">{{ time() }}</div>`,
})
// implements OnInit, OnDestroy: TypeScript checks that the methods exist
export class Clock implements OnInit, OnDestroy {
  readonly log = output<string>();
  protected readonly time = signal(new Date().toLocaleTimeString());
  private timer: number | undefined;

  constructor() {
    console.log('Clock: constructor'); // the parent does not listen to log yet
    // afterNextRender is registered in the constructor; the function is
    // called once, just after the first display of the component
    afterNextRender(() => this.log.emit('Clock: afterNextRender - displayed'));
  }

  ngOnInit(): void {
    this.log.emit('Clock: ngOnInit - starting the timer');
    this.timer = window.setInterval(() => this.time.set(new Date().toLocaleTimeString()), 1000);
  }

  ngOnDestroy(): void {
    window.clearInterval(this.timer);
    this.log.emit('Clock: ngOnDestroy - timer stopped');
  }
}

The steps:

  • lines 28–33: the constructor: the class is created. The inputs have not yet been assigned values, and the parent is not yet listening to the outputs (hence the [console.log] on line 29, rather than a [emit]);
  • lines 35–38: [ngOnInit()]: The inputs have received their first values. This is the usual place to trigger actions that depend on them: a load, a timer;
  • line 32: [afterNextRender(function)], called in the constructor, records a function executed once, immediately after the component is first displayed: its elements HTML then exist on the page;
  • Lines 40–43: [ngOnDestroy()]: the component is about to be removed from the page. The timer is stopped.

[implements OnInit, OnDestroy] (line 23) is not required: [Angular] calls [ngOnInit] if it exists. But [TypeScript] then verifies that the methods are written correctly.

[Angular] offers additional steps, which are less useful since the signals were introduced: [ngOnChanges] (an input has changed; we prefer a [computed] or a [effect] for that input), [ngAfterViewInit]... And an alternative to [ngOnDestroy]: [inject(DestroyRef).onDestroy(function)], which Example 14 will use.

3.4.2. The parent

// =========================================================================
// 13-lifecycle/src/app/app.ts
// -------------------------------------------------------------------------
//   - @if around <app-clock>: checking / unchecking the box creates / destroys
//     the component (ngOnInit / ngOnDestroy, see clock.ts);
//   - viewChild('field'): a signal that gives the HTML element marked #field
//     in the template (undefined as long as it is not displayed);
//   - effect: executed a first time, then on each change of the
//     signals it reads. Here, to give the focus to the field as soon as it
//     appears, and so that the title of the tab follows what is typed.
// =========================================================================

import { Component, effect, ElementRef, OnInit, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Clock } from './clock/clock';

@Component({
  selector: 'app-root',
  imports: [FormsModule, Clock],
  templateUrl: './app.html',
})
export class App implements OnInit {
  protected readonly withClock = signal(true);
  protected readonly log = signal<string[]>([]);
  protected readonly search = signal('');
  protected readonly showSearch = signal(false);

  // the <input #field> element of the template; ElementRef.nativeElement: the HTML element
  private readonly field = viewChild<ElementRef<HTMLInputElement>>('field');

  constructor() {
    // the field appears (viewChild changes): we give it the focus
    effect(() => this.field()?.nativeElement.focus());
    // the title of the tab follows what is typed, from the start
    effect(() => {
      const text = this.search();
      document.title = text ? `Search: ${text}` : 'Example 13';
    });
  }

  ngOnInit(): void {
    this.trace('App: ngOnInit');
  }

  protected trace(message: string): void {
    this.log.update((j) => [...j, `${new Date().toLocaleTimeString()} ${message}`]);
  }
}
  • line 29: [viewChild('field')]: a signal that refers to the template element marked [#field]. It is set to [undefined] as long as the element does not exist (the field appears only after a click). [ElementRef] wraps the element HTML, which is accessible via its property [nativeElement];
  • line 33: a [effect] that reads this signal: when the field appears, the effect is triggered again and gives it focus. There is no need to “wait” for the page to refresh: the signal only changes once the element has been created;
  • lines 35–38: a second effect: the tab title updates as text is entered, starting from the very beginning (an effect always runs once at the start);
  • lines 41–43: the parent element also has its own [ngOnInit].
<div class="container py-4" style="max-width: 720px">
  <h1 class="h3 mb-3">Example 13 - the lifecycle of a component</h1>

  <div class="card p-3 mb-3">
    <label class="form-check-label mb-2">
      <input type="checkbox" class="form-check-input" [(ngModel)]="withClock"> show the clock
    </label>
    @if (withClock()) {
      <app-clock (log)="trace($event)" />
    }
  </div>

  <div class="card p-3 mb-3">
    @if (!showSearch()) {
      <button class="btn btn-outline-primary align-self-start" (click)="showSearch.set(true)">Search...</button>
    } @else {
      <!-- #field: the element designated by viewChild('field') -->
      <input #field class="form-control" placeholder="the tab title follows what you type" [(ngModel)]="search">
    }
  </div>

  <h2 class="h6">Log</h2>
  <ol class="small">
    @for (line of log(); track $index) {
      <li>{{ line }}</li>
    }
  </ol>
</div>
  • lines 8–10: Checking or unchecking the box creates or destroys the clock: [@if] actually adds or removes the component from the page;
  • line 18: [#field], the element referred to by [viewChild('field')].

[npm start 13-lifecycle]; uncheck and then recheck the box, and click “Search...”:

Image

The log shows the order of the steps: first the parent ([App: ngOnInit]), then the clock ([ngOnInit], [afterNextRender]), its destruction ([ngOnDestroy]), and a new clock. The search field has focus.

3.5. Example 14: Services and Dependency Injection

A service is a class that groups together data and code shared by multiple components: server access, the logged-in user, a shopping cart, etc. Components do not create services (never [new CartService()]): they request them from [Angular], using the [inject()] function. This is dependency injection, the mechanism that the [NestJS] course presented on the server side.

When a component requests a service, [Angular] looks for who provides it: the component itself, then its parent, the parent of its parent… all the way up to the application. Depending on where it is provided, a service is therefore shared to a greater or lesser extent. This example shows three levels.

3.5.1. A root service

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class LogService {
  private readonly _lines = signal<string[]>([]);
  // asReadonly(): the components READ the lines; only the service modifies them
  readonly lines = this._lines.asReadonly();

  trace(message: string): void {
    this._lines.update((l) => [`${new Date().toLocaleTimeString()} ${message}`, ...l].slice(0, 6));
  }
}
  • line 14: [@Injectable({ providedIn: 'root' })]: the service is provided at the root of the application. [Angular] creates a single instance the first time it is requested, and everyone who requests it receives that instance;
  • Lines 16–18: The data in the lines is private; [asReadonly()] provides a public, read-only version of it. The components read the lines; only the service modifies them (line 21). This is the standard organization for a service that maintains state.

3.5.2. A service provided by a component

import { computed, inject, Injectable, signal } from '@angular/core';
import { LogService } from './log.service';

export interface Item {
  label: string;
  price: number;
}

@Injectable()
export class CartService {
  // a service can itself request other services
  private readonly log = inject(LogService);

  private readonly _items = signal<Item[]>([]);
  readonly items = this._items.asReadonly();
  readonly total = computed(() => this._items().reduce((sum, a) => sum + a.price, 0));

  add(item: Item): void {
    this._items.update((a) => [...a, item]);
    this.log.trace(`cart: + ${item.label}`);
  }

  remove(index: number): void {
    this.log.trace(`cart: - ${this._items()[index].label}`);
    this._items.update((a) => a.filter((_, i) => i !== index));
  }
}
  • line 19: [@Injectable()] WITHOUT [providedIn]: the service is not provided by default anywhere. A component will provide it in its array [providers];
  • line 22: a service can itself request other services: the shopping cart is logged;
  • lines 24–26: the shopping cart's status (private), its read-only version, and its total;
  • lines 28–36: the two actions that change the status.

3.5.3. A configured service

import { DestroyRef, inject, Injectable, InjectionToken, signal } from '@angular/core';

// factory: the value used if nobody provides this token
export const CLOCK_PERIOD = new InjectionToken<number>('period of the clock', { factory: () => 1000 });

@Injectable()
export class ClockService {
  private readonly period = inject(CLOCK_PERIOD);
  readonly now = signal(new Date());

  constructor() {
    const timer = window.setInterval(() => this.now.set(new Date()), this.period);
    inject(DestroyRef).onDestroy(() => window.clearInterval(timer));
  }
}
  • line 15: an injection token ([InjectionToken]) refers to a value that is not a class: in this case, a number—the clock period. [factory] provides the value used if no one supplies this token;
  • line 19: the token value is requested in the same way a service is requested;
  • line 24: [DestroyRef] represents the “lifetime” of the component that provided the service; [onDestroy] registers a function to be executed when it is destroyed: the timer stops along with the component that provided the clock.

[inject()] is called only in an injection context: when initializing the properties of a class created by [Angular] (component, service, directive, pipe), or in its constructor. It cannot be called later, within an ordinary method.

3.5.4. Components

A catalog that adds items to the shopping cart:

// 14-services/src/app/catalog/catalog.ts - a descendant that INJECTS the cart
import { Component, inject } from '@angular/core';
import { Item, CartService } from '../services/cart.service';

@Component({
  selector: 'app-catalog',
  template: `
    <ul class="list-group">
      @for (a of catalog; track a.label) {
        <li 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>
  `,
})
export class Catalog {
  // the cart provided by App (error at runtime if nobody provides it)
  protected readonly cart = inject(CartService);

  protected readonly catalog: Item[] = [
    { label: 'Consultation', price: 30 },
    { label: 'Vaccine', price: 12.5 },
    { label: 'Certificate', price: 5 },
  ];
}
  • line 20: [inject(CartService)]: the shopping cart provided by a parent ([App]). If no one provided it, [Angular] would report a runtime error;
  • Line 12: The template directly calls a method of the service.

A summary of the shopping cart:

import { Component, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { CartService } from '../services/cart.service';
import { ClockService, CLOCK_PERIOD } from '../services/clock.service';

@Component({
  selector: 'app-cart-summary',
  imports: [DatePipe],
  // a new instance of ClockService for THIS component, with another
  // period: { provide: token, useValue: value }
  providers: [ClockService, { provide: CLOCK_PERIOD, useValue: 5000 }],
  template: `
    <div class="card p-3">
      <h2 class="h6">Cart ({{ cart.items().length }} item(s)) - {{ clock.now() | date: 'HH:mm:ss' }}</h2>
      <ul class="mb-2">
        @for (a of cart.items(); track $index) {
          <li>{{ a.label }} <a href="#" (click)="$event.preventDefault(); cart.remove($index)">remove</a></li>
        }
      </ul>
      <strong>Total: €{{ cart.total() }}</strong>
    </div>
  `,
})
export class CartSummary {
  protected readonly cart = inject(CartService);
  protected readonly clock = inject(ClockService);
}
  • line 18: the component provides its own clock (a new instance of [ClockService]) and another time period: [{ provide: token, useValue: value }];
  • Lines 32–33: The shopping cart is that of [App] (the same as the one in the catalog); the clock is its own.

And the root component:

import { Component, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Catalog } from './catalog/catalog';
import { CartSummary } from './cart-summary/cart-summary';
import { ClockService } from './services/clock.service';
import { LogService } from './services/log.service';
import { CartService } from './services/cart.service';

@Component({
  selector: 'app-root',
  imports: [DatePipe, Catalog, CartSummary],
  providers: [CartService, ClockService],
  templateUrl: './app.html',
})
export class App {
  protected readonly clock = inject(ClockService);
  protected readonly log = inject(LogService);
}
  • line 23: [App] provides a cart (shared by it and all its descendants) and a clock (default period: one second);
  • line 28: the log, root service.
<div class="container py-4" style="max-width: 720px">
  <h1 class="h3 mb-3">Example 14 - services and dependency injection</h1>
  <p class="text-muted">Clock of App: {{ clock.now() | date: 'HH:mm:ss' }}</p>
  <div class="row g-3">
    <div class="col-md-6"><app-catalog /></div>
    <div class="col-md-6"><app-cart-summary /></div>
  </div>
  <h2 class="h6 mt-3">Log (LogService)</h2>
  <ul class="small">
    @for (line of log.lines(); track $index) {
      <li>{{ line }}</li>
    }
  </ul>
</div>

[npm start 14-services]; three items are added, one is removed:

Image

The two timers do not advance at the same rate: each component has received the instance closest to it. The shopping cart, meanwhile, is shared by both the catalog and the summary.

Services are the means, in [Angular], of sharing logic and data between components (the role played by “composables” and [provide] / [inject] in [Vue.js]). Example 20 will turn this into a state shared by the entire application.

3.6. Example 15: Writing Your Own Directives

A component is a directive that has a template. A directive “on its own” does not have one: it attaches to an existing element (its host) to modify its appearance or behavior. [Angular] provides some ([ngModel], [routerLink], [ngTemplateOutlet]...); you can write your own. Here are three examples.

3.6.1. Highlight an element on hover

// =========================================================================
// 15-directives/src/app/directives/highlight.directive.ts
// -------------------------------------------------------------------------
// An ATTRIBUTE DIRECTIVE adds a behavior to an existing element,
// without a template of its own: <li appHighlight="gold">. Its selector is a
// CSS attribute selector ([appHighlight]).
// host: the bindings that the directive sets on the element that carries it
// (its "host"):
//   - '[style.background-color]' : a property of the element;
//   - '(mouseenter)'             : an event of the element.
// =========================================================================

import { Directive, input, signal } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  host: {
    '[style.background-color]': 'hovered() ? color() : null',
    '[style.transition]': '"background-color 0.2s"',
    '(mouseenter)': 'hovered.set(true)',
    '(mouseleave)': 'hovered.set(false)',
  },
})
export class HighlightDirective {
  // the input has the name of the selector: <li appHighlight="gold"> sets it
  readonly color = input('#fff3cd', { alias: 'appHighlight', transform: (c: string) => c || '#fff3cd' });
  protected readonly hovered = signal(false);
}
  • line 15: [@Directive] instead of [@Component]: no template;
  • line 16: the selector is an attribute selector CSS: the directive applies to any element that has the attribute [appHighlight];
  • lines 17–22: [host]: the bindings that the directive applies to its host, written as in a template:
    • line 18: a property binding: the host’s background color when the mouse hovers over it ([null]: no color);
    • lines 20–21: event bindings: mouse entry and exit;
  • Line 26: An input named after the selector (option [alias]): [<li appHighlight="gold">] populates both the selector and the input. The function [transform] replaces an empty string with the default color.

3.6.2. Set focus

// =========================================================================
// 15-directives/src/app/directives/autofocus.directive.ts
// -------------------------------------------------------------------------
// <input appAutofocus>: the field receives the focus as soon as it is displayed.
// inject(ElementRef) gives the host HTML element (nativeElement).
// (The HTML autofocus attribute only works when the page loads;
// in a single-page application, the fields appear later.)
// =========================================================================

import { afterNextRender, Directive, ElementRef, inject } from '@angular/core';

@Directive({ selector: '[appAutofocus]' })
export class AutofocusDirective {
  constructor() {
    const element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
    afterNextRender(() => element.focus());
  }
}
  • line 15: [inject(ElementRef)] specifies the host element;
  • line 16: focus is given to it as soon as it is displayed. The attribute HTML [autofocus] only works when the page loads; in a single-page application, the fields appear later.

3.6.3. A long click

A directive can also trigger events. This one triggers its event only if the host is held down for one second: a safeguard against accidental clicks.

// =========================================================================
// 15-directives/src/app/directives/long-press.directive.ts
// -------------------------------------------------------------------------
// A directive can also EMIT events (output):
//     <button (appLongPress)="remove()">
// The event is only emitted if the button is held down for one second:
// a protection against unfortunate clicks. During the press, the CSS
// class "pressed" is set on the button.
// =========================================================================

import { DestroyRef, Directive, inject, input, output, signal } from '@angular/core';

@Directive({
  selector: '[appLongPress]',
  host: {
    '[class.pressed]': 'pressing()',
    '(pointerdown)': 'start()',
    '(pointerup)': 'stop()',
    '(pointerleave)': 'stop()',
  },
})
export class LongPressDirective {
  readonly duration = input(1000); // in milliseconds
  readonly appLongPress = output<void>();
  protected readonly pressing = signal(false);
  private timer: number | undefined;

  constructor() {
    inject(DestroyRef).onDestroy(() => window.clearTimeout(this.timer));
  }

  protected start(): void {
    this.pressing.set(true);
    this.timer = window.setTimeout(() => {
      this.pressing.set(false);
      this.appLongPress.emit();
    }, this.duration());
  }

  protected stop(): void {
    window.clearTimeout(this.timer);
    this.pressing.set(false);
  }
}
  • line 16: while the host is pressed, the class [pressed] is applied to the host;
  • lines 17–19: pointer events (mouse, finger, stylus): press, release, exit the element;
  • line 24: an output bearing the selector’s name: we write [(appLongPress)="..."], and apply the directive at the same time as we listen for its event;
  • line 29: the timer is stopped if the host disappears.

3.6.4. Usage

// =========================================================================
// 15-directives/src/app/app.ts - writing your own directives
// -------------------------------------------------------------------------
// A component is a directive WITH a template. A "plain" directive
// has no template: it attaches itself to an existing element to
// modify its appearance or its behavior. Like a component, it must
// be imported by the component that uses it.
// =========================================================================

import { Component, signal } from '@angular/core';
import { AutofocusDirective } from './directives/autofocus.directive';
import { LongPressDirective } from './directives/long-press.directive';
import { HighlightDirective } from './directives/highlight.directive';

@Component({
  selector: 'app-root',
  imports: [AutofocusDirective, LongPressDirective, HighlightDirective],
  templateUrl: './app.html',
  styles: `
    .pressed {
      outline: 4px solid #dc3545;
    }
  `,
})
export class App {
  protected readonly doctors = signal(['Mme Marie PELISSIER', 'Mr Paul TATOU', 'Melle Sophie DUPONT']);
  protected readonly message = signal('');

  protected remove(name: string): void {
    this.doctors.update((m) => m.filter((x) => x !== name));
    this.message.set(`${name} has been deleted.`);
  }
}
  • line 17: Like a component, a directive must be imported by the component that uses it;
  • lines 19–23: the style of the [pressed] class, set by the long-click directive.
<div class="container py-4" style="max-width: 640px">
  <h1 class="h3 mb-3">Example 15 - writing your own directives</h1>
  <!-- appAutofocus: the field has the focus as soon as the page is displayed -->
  <input appAutofocus class="form-control mb-3" placeholder="this field received the focus by itself">

  <p class="small text-muted">Hover over the rows; hold a button down for one second to delete.</p>
  <ul class="list-group mb-3">
    @for (m of doctors(); track m; let even = $even) {
      <!-- appHighlight: without a value, the default color; with one, the given color -->
      <li class="list-group-item d-flex justify-content-between align-items-center"
          [appHighlight]="even ? '' : '#d1e7dd'">
        {{ m }}
        <button class="btn btn-sm btn-outline-danger" (appLongPress)="remove(m)">Delete (long press)</button>
      </li>
    }
  </ul>
  @if (message()) {
    <p class="alert alert-info">{{ message() }}</p>
  }
</div>
  • Line 4: The field will have focus as soon as it is displayed;
  • line 8: [let even = $even]: Is the row number even?
  • line 11: every other row is highlighted in the default color, the other in green;
  • Line 13: A long press removes the doctor.

[npm start 15-directives]; hold down the second button, then hover over the first row:

Image

3.7. Example 16: A Reusable Confirmation Window

Before deleting an item, the user is asked to confirm. The browser provides [window.confirm()], but its window cannot be formatted or translated. This example replaces it with a [Bootstrap] window, combining what the previous examples have demonstrated: a service (Example 14) that carries the window’s state in a signal, and a component that displays it. The case study will use exactly this mechanism.

1
2
3
4
5
6
16-confirmation/src/app/
├── confirmation/
│   ├── confirmation.service.ts   the service: the question asked, the answer
│   └── confirm-dialog.ts         the modal dialog
├── app.ts
└── app.html

3.7.1. The service

// =========================================================================
// 16-confirmation/src/app/confirmation/confirmation.service.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 (confirm-dialog.ts), driven by this service.
//
// 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 this.confirmation.confirm('Delete this doctor?')) { ... }
//
// providedIn: 'root': only one service for the whole application, hence only one
// dialog, placed once in the root component.
// =========================================================================

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ConfirmationService {
  private readonly _message = signal<string | null>(null); // null: dialog closed
  readonly message = this._message.asReadonly();
  private answer: ((yes: boolean) => void) | null = null;

  confirm(text: string): Promise<boolean> {
    this.answer?.(false); // a question still open? it counts as "no"
    this._message.set(text);
    return new Promise<boolean>((resolve) => {
      this.answer = resolve;
    });
  }

  respond(yes: boolean): void {
    this._message.set(null);
    const r = this.answer;
    this.answer = null;
    r?.(yes);
  }
}
  • line 20: a single service for the entire application; therefore, there will be only one window, placed once and for all in the root component;
  • line 22: the displayed message; [null] means that the window is closed;
  • line 23: [asReadonly()]: components can read the message, but only the service can modify it;
  • line 24: the function that will resolve the current promise, which is awaiting a response;
  • lines 26–32: [confirm] opens the window (line 28) and returns a promise. Its function [resolve] is set aside (line 30): the promise will not be resolved until later, when the user clicks a button;
  • line 27: if a question were still open, it would be answered “no”: its promise will not remain pending forever;
  • lines 34–39: [respond] is called by the window: it closes the window (line 35) and then resolves the promise with the user’s response (line 38).

This is the benefit of the promise: the code that asks the question writes it as a call to [window.confirm()], waiting for the response with [await], without a callback function.

3.7.2. The window

// =========================================================================
// 16-confirmation/src/app/confirmation/confirm-dialog.ts
// -------------------------------------------------------------------------
// The modal dialog, placed ONCE in the root component:
//   - same Bootstrap classes as a real modal (.modal, .modal-backdrop),
//     displayed "statically" (show d-block): no need for the JavaScript of
//     Bootstrap;
//   - keyboard: Escape = No; the focus is put on the "No" button (the
//     least dangerous choice) on opening.
// '(document:keydown.escape)' in host: an event listened to on the whole
// DOCUMENT, and not on the element of the component.
// =========================================================================

import { Component, effect, ElementRef, inject, viewChild } from '@angular/core';
import { ConfirmationService } from './confirmation.service';

@Component({
  selector: 'app-confirm-dialog',
  host: { '(document:keydown.escape)': 'confirmation.message() !== null && confirmation.respond(false)' },
  template: `
    @if (confirmation.message(); as message) {
      <div class="modal-backdrop fade show"></div>
      <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true"
           (click)="$event.target === $event.currentTarget && confirmation.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)="confirmation.respond(false)"></button>
            </div>
            <div class="modal-body">
              <p class="mb-0">{{ message }}</p>
            </div>
            <div class="modal-footer">
              <button #noButton type="button" class="btn btn-secondary" (click)="confirmation.respond(false)">No, go back</button>
              <button type="button" class="btn btn-danger" (click)="confirmation.respond(true)">Yes, confirm</button>
            </div>
          </div>
        </div>
      </div>
    }
  `,
})
export class ConfirmDialog {
  protected readonly confirmation = inject(ConfirmationService);
  private readonly noButton = viewChild<ElementRef<HTMLButtonElement>>('noButton');

  constructor() {
    // on opening, the "No" button appears: it receives the focus
    effect(() => this.noButton()?.nativeElement.focus());
  }
}
  • line 19: [document:keydown.escape]: the event is listened for on the entire document, not on the component’s element. The Esc key responds with “no” if a question is asked;
  • line 21: the window only appears if a message needs to be displayed; [as message] assigns a name to the signal’s value;
  • Lines 22–23: The classes of a true modal [Bootstrap], displayed directly ([show d-block]): The [JavaScript] derived from [Bootstrap] is unnecessary, since [Angular] determines the display;
  • line 24: a click next to the dialog box (on the gray background) responds with “no.” [$event.target === $event.currentTarget]: the click occurred on the [.modal] element itself, not on one of its children;
  • lines 35–36: both responses. The “No” button carries the template variable [#noButton];
  • line 46: [viewChild] (example 13) returns the element marked [#noButton] as a signal: it is equal to [undefined] as long as the window is closed;
  • line 50: an effect that depends on this signal: each time the window is opened, the “No” button appears, the signal changes, and the button receives focus. We chose “No,” the least dangerous response: pressing the Enter key by mistake does not delete anything.

3.7.3. Usage

// =========================================================================
// 16-confirmation/src/app/app.ts - using the confirmation dialog
// -------------------------------------------------------------------------
// Same page as example 21 of the NestJS course (links, buttons, confirmation),
// but without any round trip to a server.
// =========================================================================

import { Component, inject, signal } from '@angular/core';
import { ConfirmationService } from './confirmation/confirmation.service';
import { ConfirmDialog } from './confirmation/confirm-dialog';

interface Contact {
  id: number;
  name: string;
}

@Component({
  selector: 'app-root',
  imports: [ConfirmDialog],
  templateUrl: './app.html',
})
export class App {
  private readonly confirmation = inject(ConfirmationService);
  protected readonly contacts = signal<Contact[]>([
    { id: 1, name: 'Jean MARTIN' },
    { id: 2, name: 'Alice DURAND' },
    { id: 3, name: 'Luc GERMAN' },
  ]);
  protected readonly message = signal('');

  // an async method: it "waits" for the answer of the user
  protected async remove(c: Contact): Promise<void> {
    if (!(await this.confirmation.confirm(`Delete the contact ${c.name}?`))) {
      this.message.set('Deletion cancelled.');
      return;
    }
    this.contacts.update((list) => list.filter((x) => x.id !== c.id));
    this.message.set(`${c.name} has been deleted.`);
  }

  protected async removeAll(): Promise<void> {
    if (await this.confirmation.confirm(`Delete the ${this.contacts().length} contacts?`)) {
      this.contacts.set([]);
      this.message.set('All the contacts have been deleted.');
    }
  }
}
  • line 19: the root component imports the window, which it will place in its template;
  • line 23: it requests the service;
  • lines 32–39: a method named [async]. On line 33, [await] suspends execution until the user responds; during this time, the application remains active. The rest of the method executes only if the response is “Yes”;
  • lines 41–46: same principle applies to deleting all contacts.
<div class="container py-4" style="max-width: 640px">
  <h1 class="h3 mb-3">Example 16 - a confirmation dialog</h1>
  <ul class="list-group mb-3">
    @for (c of contacts(); track c.id) {
      <li 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>
    } @empty {
      <li class="list-group-item text-muted">No contact.</li>
    }
  </ul>
  <button class="btn btn-danger" [disabled]="contacts().length === 0" (click)="removeAll()">Delete all</button>
  @if (message()) {
    <p class="alert alert-info mt-3">{{ message() }}</p>
  }
</div>
<!-- the dialog, ONCE for the whole application -->
<app-confirm-dialog />
  • lines 4–11: [@for] may be followed by a [@empty] block, displayed when the list is empty;
  • line 13: the button is disabled when there is nothing left to delete;
  • line 19: the window, positioned once for the entire application. Any component can then open it simply by calling the service.

[npm start 16-confirmation]; click the [Delete] button for Alice DURAND:

Image

With [Vue.js], the window and its state were provided by a “composable”; with [Angular], it’s a service. The principle is the same: a shared state, in a signal, that multiple components read and modify.