Skip to content

6. Asynchronous Operations and Shared State

6.1. Example 19: Promises, Observables, and Timers

Many things happen “later”: timer ticks, keystrokes, and server responses. A [Ionic] application has two types for this:

  • [Promise<T>], that of [JavaScript]: ONE value, later (a server response, a selection made in a window). The keywords [async] and [await] allow you to wait for it without blocking the application. This is the [Future] of [Dart];
  • [Observable<T>], which is derived from the [RxJS] library, shipped with [Angular]: a SEQUENCE of values, over time (ticks, keystrokes, messages...), which are transformed by operators ([map], [take], [debounceTime], [switchMap]...). This is the [Stream] derived from [Dart]. [JavaScript] also has its asynchronous counterparts: the asynchronous generators ([async function*]), which are the counterparts to the [async*] functions of [Dart].

To display them, [Angular] provides three tools, the counterparts of [FutureBuilder] and [StreamBuilder]: the pipe [async] (a [Observable] read from the template), [toSignal()] (a [Observable] converted into a signal), and [resource()] (a [Promise] converted into signals). And for what must happen after a delay: [setTimeout], or the timers from [RxJS] ([timer], [interval]).

6.1.1. The page

// =========================================================================
// src/app/ex19-asynchronous/ex19.page.ts - promises, observables, timers
// -------------------------------------------------------------------------
// Many things happen "later": the ticks of a timer, the
// keystrokes, the response of a server. Two types are available:
//   - Promise (JavaScript): ONE value, later (the response of a
//     server); async / await to wait for it;
//   - Observable (RxJS library, shipped with Angular): a SEQUENCE of
//     values, over time (ticks, keystrokes...), which we transform
//     with "operators" (map, take, debounceTime, switchMap...).
// To display them, Angular offers the async pipe (an Observable in the
// template), toSignal() (an Observable turned into a signal) and resource() (a
// Promise turned into a signal).
// =========================================================================

import { AsyncPipe } from '@angular/common';
import { Component, resource, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import {
  IonBackButton, IonButtons, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonContent, IonHeader, IonInput,
  IonNote, IonTitle, IonToolbar,
} from '@ionic/angular';
import { debounceTime, finalize, from, map, Observable, of, Subject, switchMap, take, tap, timer } from 'rxjs';

const doctors = ['Marie PELISSIER', 'Paul TATOU', 'Sophie DUPONT', 'Jacques DURAND', 'Martine PELLETIER'];

// a timer in the form of a Promise: it finishes after ms milliseconds
function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// a simulated "server": the response (a Promise) arrives after a random delay
async function search(text: string): Promise<string[]> {
  await wait(200 + Math.floor(Math.random() * 600));
  return doctors.filter((m) => m.toLowerCase().includes(text));
}

// 1a. an ASYNCHRONOUS GENERATOR (async function*, with a star): each
// yield SENDS a value to whoever iterates over it (for await...of)
async function* countdown(start: number): AsyncGenerator<number> {
  for (let remaining = start; remaining >= 0; remaining--) {
    yield remaining;
    if (remaining > 0) await wait(1000);
  }
}

// "15:30:45"
const currentTime = () => new Date().toTimeString().substring(0, 8);

@Component({
  selector: 'app-ex19',
  imports: [
    AsyncPipe, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonCard, IonCardHeader,
    IonCardSubtitle, IonCardContent, IonInput, IonNote,
  ],
  templateUrl: './ex19.page.html',
  styles: `
    .number { font-size: 2em; color: var(--ion-text-color); }
    .log { font-size: 12px; }
  `,
})
export class Ex19Page {
  readonly log = signal<string[]>([]);

  // 1a. the generator, turned into an Observable by from(); the template
  // displays it with the async pipe. Created ONCE, in a field
  readonly countdown$: Observable<number> = from(countdown(20));

  // 1b. the same countdown, with RxJS operators: timer(0, 1000)
  // emits 0, 1, 2... every second; take(21) stops after 21 values;
  // map transforms them (0 -> 20, 1 -> 19...). toSignal: a signal whose value is
  // the last value received (undefined before the first)
  readonly countdown = toSignal(timer(0, 1000).pipe(take(21), map((i) => 20 - i)));

  // 3. a Promise that finishes after one second, followed by resource():
  // an object with signals (isLoading(), hasValue(), value(), error()...)
  readonly message = resource({
    loader: async () => {
      await wait(1000);
      return 'three (one second later)';
    },
  });

  // 2. the search as you type: a Subject is an Observable into
  // which we PUSH the values ourselves (next): here, each keystroke
  private readonly keystrokes = new Subject<string>();
  readonly results = signal<string[]>([]);

  constructor() {
    this.keystrokes
      .pipe(
        // the DEBOUNCE: a value is only passed on after 400 ms without
        // a new keystroke; closer keystrokes are forgotten
        debounceTime(400),
        map((text) => text.trim().toLowerCase()),
        // switchMap: each text starts a search (an Observable); a
        // NEW search ABANDONS the previous one, whose response will
        // never be passed on: no "stale" response
        switchMap((text) => this.launch(text)),
        // the subscription ends when the page is destroyed
        takeUntilDestroyed(),
      )
      .subscribe((found) => this.results.set(found));
  }

  private trace(message: string): void {
    this.log.update((lines) => [...lines, `${currentTime()} ${message}`]);
  }

  // called on each keystroke
  input(text: string | null | undefined): void {
    this.keystrokes.next(text ?? '');
  }

  private launch(text: string): Observable<string[]> {
    // fewer than two letters: no search, no result
    if (text.length < 2) {
      return of([]);
    }
    this.trace(`search sent: "${text}"`);
    let received = false;
    // from(Promise): an Observable that emits the value of the Promise, then completes
    return from(search(text)).pipe(
      tap(() => (received = true)),
      // finalize: called when the Observable completes OR when we unsubscribe from it
      // (that is what switchMap does when a new search arrives)
      finalize(() => {
        if (!received) this.trace(`response ignored (stale): "${text}"`);
      }),
    );
  }
}
<!-- ======================================================================
     ex19.page.html - promises, observables and timers
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Example 19 - asynchrony</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-card>
    <ion-card-header>
      <ion-card-subtitle>Two countdowns (Observables)</ion-card-subtitle>
    </ion-card-header>
    <ion-card-content>
      <!-- the async pipe subscribes to the Observable, displays each new
           value, and unsubscribes when the page disappears; it is null
           before the first value. @let: a template variable -->
      @let n = countdown$ | async;
      <p>async function* and async pipe: <span class="number">{{ n ?? '...' }}</span></p>
      <!-- the signal: undefined before the first value -->
      <p>RxJS and toSignal(): <span class="number">{{ countdown() ?? '...' }}</span></p>
    </ion-card-content>
  </ion-card>

  <ion-card>
    <ion-card-header>
      <ion-card-subtitle>A search as you type (debounce, stale responses)</ion-card-subtitle>
    </ion-card-header>
    <ion-card-content>
      <!-- (ionInput): on each keystroke -->
      <ion-input placeholder="at least two letters: pel, dur..." (ionInput)="input($event.detail.value)" />
      @for (r of results(); track r) {
        <p>• {{ r }}</p>
      } @empty {
        <ion-note>no result</ion-note>
      }
    </ion-card-content>
  </ion-card>

  <ion-card>
    <ion-card-header>
      <ion-card-subtitle>Waiting for a Promise (resource)</ion-card-subtitle>
    </ion-card-header>
    <ion-card-content>
      <!-- as long as the Promise is not finished: the waiting content -->
      @if (message.hasValue()) {
        <p>received: {{ message.value() }}</p>
      } @else {
        <ion-note>waiting...</ion-note>
      }
    </ion-card-content>
  </ion-card>

  <h6>Log</h6>
  @for (line of log(); track $index) {
    <div class="log">{{ $index + 1 }}. {{ line }}</div>
  }
</ion-content>

Tools:

  • lines 28–30 (.ts): a timer in the form of [Promise]. [new Promise(...)] receives a function that starts the task and will call [resolve] when it is finished; here, [setTimeout] calls it after [ms] milliseconds. [await wait(1000)] therefore waits for one second, without blocking the application (this was [Future.delayed] with [Dart]);
  • lines 33–36 (.ts): a simulated “server”: a function [async], which returns a [Promise]; its response arrives after a random delay (between 200 and 800 milliseconds);
  • lines 40–45 (.ts): an asynchronous generator: a function [async function*] (with an asterisk). Each [yield] assigns a value to the one traversing the generator; between two values, the function waits one second. It can be traversed using a loop [for await (const n of countdown(20))].

The first countdown:

  • line 67 (.ts): the generator, converted to [Observable] by [from()], which accepts an asynchronous generator, a [Promise], an array... By convention, the name of a variable that contains a [Observable] ends with [$];
  • Line 23 (HTML): The pipe [async] subscribes to [Observable], displays each new value, and unsubscribes automatically when the page is closed; it is set to [null] before the first value is received. [@let n = ...] declares a template variable, read on line 24 ([n ?? '...']: three dots as long as [n] is equal to [null]). As with [Flutter], [Observable] is created ONCE in a class field: if the template called a method that created it, every screen update would restart the countdown!

The second one, with the [RxJS] operators:

  • line 73 (.ts): [timer(0, 1000)] outputs 0 immediately, then 1, 2, 3... every second; the [pipe] method applies operators to it, one after another: [take(21)] keeps only the first 21 values (then [Observable] ends); [map] transforms each value (0 becomes 20, 1 becomes 19...). [toSignal] subscribes to [Observable] and turns it into a signal, which is equal to the last value received ([undefined] before the first one); it unsubscribes when the page is destroyed;
  • line 26 (HTML): the signal is read like any other signal.

The expected [Promise]:

  • lines 77–82 (.ts): [resource()] receives a function [loader] that returns a [Promise], invokes it, and exposes its state as signals: [isLoading()], [hasValue()], [value()], [error()]. If we provided an option [params]—a function that reads signals—the resource would reload whenever they change (the [loader], however, is not monitored): we’ll use it to query a server;
  • lines 51–55 (HTML): as long as [Promise] is not finished, the placeholder content; afterward, its value (which was [FutureBuilder] and [ConnectionState.done]).

Search as you type:

  • line 86 (.ts): a [Subject] is a [Observable] into which we PUSH the values ourselves ([next]): here, each keystroke (lines 111–113, triggered by the field’s [(ionInput)] event, line 36 of the HTML);
  • line 94 (.ts): debouncing: we wait for the user to pause before querying the server, rather than sending a request for every keystroke. The [debounceTime(400)] operator only sends a value after 400 milliseconds without a new value: keystrokes that occur too close together are ignored. With [Flutter], you had to manually cancel and restart a timer ([Timer]) with every keystroke;
  • line 95 (.ts): the text, without spaces around it, in lowercase;
  • line 99 (.ts): expired responses. A response that arrives AFTER another search has been launched must be ignored: otherwise, the slow response from an old search would overwrite that of the new one. The operator [switchMap] handles this: for each text, it calls [launch], which returns a [Observable] (the search); when a new text arrives, it UNSUBSCRIBES, cancelling the previous search—whose response will never be transmitted—and then launches the new one. With [Flutter], it was necessary to number the searches and compare the numbers;
  • Line 101 (.ts): [takeUntilDestroyed()] terminates the subscription when the page is destroyed: without it, [Subject] and its operators would continue to exist (it was the [dispose] of [Flutter]). It must be called within an “injection context”: in this case, the constructor;
  • line 103 (.ts): [subscribe] subscribes to [Observable]: nothing happens until someone subscribes. Each list of results is stored in a signal, which the template displays (lines 37–41 of the HTML);
  • lines 117–119 (.ts): fewer than two letters: no search; [of([])] is a [Observable] that immediately returns an empty list;
  • lines 123–130 (.ts): the search: the server’s [Promise], transformed into [Observable] by [from()]. [tap] performs an action in passing, without modifying the value: note that the response has arrived. [finalize] is called when [Observable] terminates, or when the user unsubscribes from it: if the response did not arrive, it means that [switchMap] abandoned this search, and this is logged.

We type “p,” “pe,” and “pel” quickly: a single search is sent. Then “du,” followed by “dup” before the response to “du” arrives: the search for “du” is abandoned (its response is ignored) the moment the search for “dup” is sent. On the left, the page as it opens; on the right, five seconds later:

In the log, the line “response ignored” precedes the line “search sent: ‘dup’”: [switchMap] unsubscribes from the old search before launching the new one.

In [Dart], [Future], and [Stream] are in the base library, and [async], [await], [async*], [yield], and [await for] are in the language. In [JavaScript], [Promise], [async], [await], the asynchronous generators are also included in the language; but for value sequences, [Angular] relies on the [RxJS] library, whose hundred or so operators resolve issues such as debouncing and expired responses in a single line. The HTTP client of [Angular] (examples 22 through 25) also returns [Observable]. Since the arrival of signals, [Angular] reserves [RxJS] for event streams and uses [toSignal] or [resource] to display them.

6.2. Example 20: A Shared State

Same question as in previous lessons: what are the “scopes” for an application’s data?

  • A component’s local state disappears when the component is destroyed;
  • the state of a “store” is shared by all components and survives their removal… but not the application’s shutdown;
  • State stored on the device survives when the application is shut down.

6.2.1. The shopping cart

// =========================================================================
// src/app/ex20-shared-state/stores/cart.ts - a SHARED STATE: a service
// -------------------------------------------------------------------------
// A "store" is a state that lives OUTSIDE the pages and the
// components, in ONE single instance, shared by all those who read it.
// With Angular, it is a SERVICE (see example 14) whose state is made of
// SIGNALS. Usual organization:
//   - the STATE: PRIVATE signals (_items);
//   - their reading: read-only views (asReadonly);
//   - the ACTIONS: methods, the only ones to modify the state;
//   - the computed values: computed signals.
// A component obtains it with inject(Cart); its template, which reads the
// signals, is updated when THEY change, and only them.
// providedIn: 'root': the service is created on its first request and lives
// as long as the application lives: it survives the removal of a component... but
// not the stopping of the application.
// =========================================================================

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

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

@Injectable({ providedIn: 'root' })
export class Cart {
  private readonly _items = signal<Item[]>([]);
  // a NON-MODIFIABLE view of the state: only the actions change it
  readonly items = this._items.asReadonly();
  readonly count = computed(() => this._items().length);
  readonly total = computed(() => this._items().reduce((s, a) => s + a.price, 0));

  add(item: Item): void {
    // a NEW array: the signal sees that its value has changed
    this._items.update((items) => [...items, item]);
  }

  clear(): void {
    this._items.set([]);
  }
}

A store is a state that encompasses pages and components, existing as a single instance shared by all who access it. With [Angular], it is simply a service (see Example 14) whose state consists of signals: there is no library to add, whereas [Flutter] used the [provider] package around a [ChangeNotifier].

  • line 26: [providedIn: 'root']: the service is created upon its first request ([inject(Cart)]), as a single instance, and remains active as long as the application is running;
  • line 28: the state: a private signal;
  • Line 30: a read-only view of the state ([asReadonly()]): only actions change it;
  • Lines 31–32: calculated values: [computed] signals, recalculated only when the list of items changes;
  • Lines 34–41: Actions: methods, which are the only ones that modify the state. Line 36: a NEW array ([...items] copies the items), not [push] as in the old version: a signal compares the old and new values ([===]) and notifies its subscribers only if they differ. There is no need to write [notifyListeners()]: the signal handles the notification.

6.2.2. Preferences

// =========================================================================
// src/app/ex20-shared-state/stores/preferences.ts - a state that survives the stop
// -------------------------------------------------------------------------
// For a piece of data to survive the stopping of the application, it must be stored
// ON THE DEVICE. The @capacitor/preferences "plugin" offers a small
// key -> value dictionary (strings), specific to the application and
// kept on the device (Android: the SharedPreferences; in a
// browser: the localStorage). All its methods are ASYNCHRONOUS
// (they return a Promise):
//   - at startup, we read the stored values;
//   - on each change, we write them again.
// We NEVER store sensitive data there (password, token): they are
// written there in clear text.
// =========================================================================

import { effect, Injectable, signal } from '@angular/core';
// "as DeviceStorage": renamed, so as not to confuse it with our class
import { Preferences as DeviceStorage } from '@capacitor/preferences';

const darkKey = 'example20.dark';
const sizeKey = 'example20.size';

@Injectable({ providedIn: 'root' })
export class Preferences {
  // the starting state: the default values, while waiting for the reading
  private readonly _dark = signal(false);
  private readonly _size = signal(16); // text size
  readonly dark = this._dark.asReadonly();
  readonly size = this._size.asReadonly();

  constructor() {
    this.load();
    // an EFFECT: a function replayed each time a signal it reads
    // changes. It applies the preferences to the WHOLE application:
    //   - the ion-palette-dark class on <html>: Ionic's dark theme
    //     (see styles.scss);
    //   - the font size of <html>: Ionic's components have
    //     sizes in rem (multiples of this size), they follow
    effect(() => {
      document.documentElement.classList.toggle('ion-palette-dark', this._dark());
      document.documentElement.style.fontSize = `${this._size()}px`;
    });
  }

  // what the device has stored (value is null if the key is absent)
  private async load(): Promise<void> {
    const dark = await DeviceStorage.get({ key: darkKey });
    const size = await DeviceStorage.get({ key: sizeKey });
    if (dark.value !== null) this._dark.set(dark.value === 'true');
    if (size.value !== null) this._size.set(Number(size.value));
  }

  // one optional parameter per preference: we only change what is provided
  update({ dark, size }: { dark?: boolean; size?: number }): void {
    if (dark !== undefined) {
      this._dark.set(dark);
      DeviceStorage.set({ key: darkKey, value: String(dark) }); // writing on the device
    }
    if (size !== undefined) {
      this._size.set(size);
      DeviceStorage.set({ key: sizeKey, value: String(size) });
    }
  }
}

For data to survive after the application is closed, it must be stored on the device. The “plugin” [@capacitor/preferences] from [Capacitor] provides a small key-to-value dictionary (of strings), specific to the application and stored on the device: the [SharedPreferences] from [Android] (the ones used by the [shared_preferences] package from [Flutter]), and, in a browser, [localStorage]. All of its methods are asynchronous: they return a [Promise].

  • line 18: the plugin is also named [Preferences]; [as DeviceStorage] renames it in this file to avoid confusion with our class;
  • Lines 26–29: The initial state, with signals set to their default values, and their read-only views;
  • line 32: Device reading begins when the service is created. It is asynchronous: for a brief moment, the default values apply;
  • lines 39–42: an effect ([effect]): a function that is replayed every time a signal it reads changes. It applies the preferences to the WHOLE application: the CSS class [ion-palette-dark] on the [<html>] tag activates the dark theme in [Ionic] (defined in [styles.scss]); the font size in [<html>] enlarges the text, because the components in [Ionic] express their sizes in [rem] (as multiples of that size). An effect serves this purpose: to relay signals to the outside world (DOM, storage, etc.); it must not be used to calculate other signals ([computed] is designed for that);
  • lines 46–51: device read operation: [DeviceStorage.get] returns a [Promise] from [{ value }], where [value] equals [null] if the key is missing. The values are strings: they are converted;
  • lines 54–63: a single action with optional parameters (a destructured object: [{ dark, size }]): only the provided value is changed, and it is immediately written to the device ([DeviceStorage.set]). We do not wait for the write operation to complete: the signal has already changed.

NEVER store sensitive data (passwords, tokens) there: it is written in plain text. For this purpose, there are plugins that use the system’s vault ([Keystore] from [Android], [Keychain] from [iOS]).

6.2.3. The components

// =========================================================================
// src/app/ex20-shared-state/components/shop.ts - ADDS to the shared cart
// -------------------------------------------------------------------------
// The shop only CALLS an action of the cart; its template reads
// no signal of the cart: it is not updated when the cart changes.
// =========================================================================

import { CurrencyPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { IonButton, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonIcon } from '@ionic/angular';
import { addIcons } from 'ionicons';
import { add } from 'ionicons/icons';
import { Item, Cart } from '../stores/cart';

const products: Item[] = [
  { label: 'Consultation', price: 30 },
  { label: 'Vaccine', price: 12.5 },
];

@Component({
  selector: 'app-ex20-shop',
  imports: [CurrencyPipe, IonCard, IonCardHeader, IonCardSubtitle, IonCardContent, IonButton, IonIcon],
  templateUrl: './shop.html',
})
export class Shop {
  readonly products = products;
  // the SAME cart as that of the other components (a single instance)
  readonly cart = inject(Cart);

  constructor() {
    addIcons({ add });
  }
}
<!-- ======================================================================
     shop.html - two products to add to the cart
     ====================================================================== -->

<ion-card>
  <ion-card-header>
    <ion-card-subtitle>Shop</ion-card-subtitle>
  </ion-card-header>
  <ion-card-content>
    @for (p of products; track p.label) {
      <ion-button fill="clear" (click)="cart.add(p)">
        <ion-icon slot="start" name="add" />
        {{ p.label }} ({{ p.price | currency: 'EUR' }})
      </ion-button>
    }
  </ion-card-content>
</ion-card>
  • line 28 (.ts): [inject(Cart)] returns the SAME shopping cart as the other components;
  • line 11 (html): the shop simply calls an action. Its template does not read any signals from the shopping cart: it is not updated when the shopping cart changes (this was [context.read<Cart>()] with [provider]);
  • line 13 (html): the [currency] pipe formats the price in the English style (see example 09).
// =========================================================================
// src/app/ex20-shared-state/components/cart-summary.ts - READS the same cart
// -------------------------------------------------------------------------
// The template reads two signals of the cart, count() and total(): it is updated
// when ONE of them changes, and only then.
// =========================================================================

import { CurrencyPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { IonButton, IonCard, IonIcon, IonItem, IonLabel } from '@ionic/angular';
import { addIcons } from 'ionicons';
import { cart } from 'ionicons/icons';
import { Cart } from '../stores/cart';

@Component({
  selector: 'app-ex20-cart-summary',
  imports: [CurrencyPipe, IonCard, IonItem, IonIcon, IonLabel, IonButton],
  templateUrl: './cart-summary.html',
})
export class CartSummary {
  readonly cart = inject(Cart);

  constructor() {
    addIcons({ cart });
  }
}
<!-- ======================================================================
     cart-summary.html - the number of items and the total of the cart
     ====================================================================== -->

<ion-card>
  <ion-item lines="none">
    <ion-icon slot="start" name="cart" />
    <ion-label>{{ cart.count() }} item(s), {{ cart.total() | currency: 'EUR' }}</ion-label>
    <ion-button slot="end" fill="clear" [disabled]="cart.count() === 0" (click)="cart.clear()">
      Clear
    </ion-button>
  </ion-item>
</ion-card>
  • Lines 8–9 (HTML): The template reads two signals from the shopping cart, [count()] and [total()]: It is updated when either of them changes, and only then. With [provider], you had to choose between [context.watch] (the entire shopping cart) and [context.select] (a single item); with the signals, the precision is automatic: it depends on what is read.
// =========================================================================
// src/app/ex20-shared-state/components/local-counter.ts - a LOCAL state
// -------------------------------------------------------------------------
// The clicks signal belongs to the component: it is born with it and disappears
// with it.
// =========================================================================

import { Component, input, signal } from '@angular/core';
import { IonButton, IonCard, IonItem, IonLabel } from '@ionic/angular';

@Component({
  selector: 'app-ex20-local-counter',
  imports: [IonCard, IonItem, IonLabel, IonButton],
  templateUrl: './local-counter.html',
})
export class LocalCounter {
  readonly title = input.required<string>();
  readonly clicks = signal(0);
}
<!-- ======================================================================
     local-counter.html - a click counter
     ====================================================================== -->

<ion-card>
  <ion-item lines="none">
    <ion-label>{{ title() }}</ion-label>
    <ion-button slot="end" fill="outline" (click)="clicks.set(clicks() + 1)">{{ clicks() }} click(s)</ion-button>
  </ion-item>
</ion-card>

A local state (line 18 of the .ts file), which is created and destroyed along with the component.

6.2.4. The page

// =========================================================================
// src/app/ex20-shared-state/ex20.page.ts - three "scopes" of data
// -------------------------------------------------------------------------
//   - the LOCAL state of a component (LocalCounter) disappears with it;
//   - the state of a SERVICE (the cart) is shared by all the components
//     and survives their removal - but not the stopping of the application;
//   - the state stored on the device (the preferences) survives the stop.
// And the CSS class ion-hide: the content is HIDDEN, but not removed; its
// local state is kept.
// Uncheck then check again "show the area", then stop and restart
// the application: compare.
// =========================================================================

import { Component, inject, signal } from '@angular/core';
import {
  IonBackButton, IonButtons, IonCheckbox, IonContent, IonHeader, IonItem, IonLabel, IonList, IonRange, IonTitle,
  IonToggle, IonToolbar,
} from '@ionic/angular';
import { Shop } from './components/shop';
import { LocalCounter } from './components/local-counter';
import { CartSummary } from './components/cart-summary';
import { Preferences } from './stores/preferences';

@Component({
  selector: 'app-ex20',
  imports: [
    IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonList, IonItem, IonLabel, IonToggle,
    IonRange, IonCheckbox, Shop, CartSummary, LocalCounter,
  ],
  templateUrl: './ex20.page.html',
})
export class Ex20Page {
  readonly preferences = inject(Preferences);
  // a local state of the page
  readonly areaVisible = signal(true);
}
<!-- ======================================================================
     ex20.page.html - a shared state
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Example 20 - a shared state</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-list>
    <ion-item>
      <!-- [checked]: the value of the service; (ionChange): the action that modifies it -->
      <ion-toggle [checked]="preferences.dark()"
        (ionChange)="preferences.update({ dark: $event.detail.checked })">dark theme</ion-toggle>
    </ion-item>
    <ion-item>
      <!-- snaps / ticks: notches, every step -->
      <ion-range [min]="12" [max]="22" [step]="1" [snaps]="true" [ticks]="true" [value]="preferences.size()"
        (ionChange)="preferences.update({ size: +$event.detail.value })">
        <ion-label slot="label">text size: {{ preferences.size() }}</ion-label>
      </ion-range>
    </ion-item>
    <ion-item>
      <ion-checkbox [checked]="areaVisible()" (ionChange)="areaVisible.set($event.detail.checked)">
        show the area</ion-checkbox>
    </ion-item>
  </ion-list>

  <!-- removed: these components disappear, with their local state -->
  @if (areaVisible()) {
    <app-ex20-shop />
    <app-ex20-cart-summary />
    <app-ex20-local-counter title="Local state" />
  }
  <!-- hidden (ion-hide: display none): the component stays in memory, its state is kept -->
  <app-ex20-local-counter title="Local state, hidden by a CSS class" [class.ion-hide]="!areaVisible()" />

  <p>Outside the area, a second summary of the SAME cart (it stays there when the area disappears):</p>
  <app-ex20-cart-summary />
</ion-content>
  • line 33 (.ts): preferences, a service; it is created (and reads the device) on its first request;
  • line 35 (.ts): a local state of the page: is the area displayed?
  • lines 18–19 (html): a toggle switch ([<ion-toggle>]): [[checked]] receives the value from the service, and [(ionChange)] calls its action;
  • lines 23–26 (html): a slider ([<ion-range>]) ranging from 12 to 22, with tick marks ([snaps], [ticks]); [+$event.detail.value] converts the value to a number;
  • lines 29–30 (html): a checkbox, linked to the local state;
  • lines 35–39 (HTML): removed ([@if]); the area disappears—its components are DESTROYED, along with their local state;
  • line 41 (html): hidden by the class CSS [ion-hide] of [Ionic] ([display: none]); the component remains in memory: its state is preserved. [[class.ion-hide]="..."] sets the class when the expression is true. (This was previously [Visibility(maintainState: true)] of [Flutter].)
  • Line 44 (html): a second summary of the same shopping cart, outside the area.

The shopping cart and preferences are shared across the entire application ([providedIn: 'root']). To make a service specific to a page (one instance per page, destroyed along with it), it is declared in the [providers] of its component, as shown in Example 21.

6.2.5. Execution

We add two items, click the first local counter twice and the second once (on the left); we remove the area and switch to the dark theme (in the middle); we restore the area (on the right):

The shopping cart survived the removal of the section (as shown in the second summary); the removed counter reset to zero; the hidden counter retained its value. Stop the application and restart it (in the browser: reload the page): the dark theme and text size are preserved, and the shopping cart is empty. The preferences apply to the entire application: when you return to the menu, it is also in dark mode.

Why not use a state management library? There are some available for [Angular] ([NgRx], its variant [SignalStore], [NGXS]...), just as there are for [Flutter] ([provider], [Riverpod], [Bloc]...). But dependency injection and signals already handle the essentials: a signal-based service is a store, and that is what the case study will use.