📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~7 min de lecture

@let dans les templates Angular : 5 patterns pour virer tes computed() de présentation

Tu ouvres un composant. Tu comptes les computed(). Tu en as 12. Sur les 12, il y en a 3 qui font de la vraie logique métier. Les 9 autres ? Ils existent pour reformater un nom, concaténer deux champs, ou exposer une sous-propriété au template parce que user()?.profile?.name répété cinq fois, ça pique les yeux.

Ces computed() n'ont rien à faire dans ton TypeScript. Ils ne servent ni à un test, ni à une autre méthode, ni à un autre composant. Ce sont des variables de présentation qui squattent ta class pour des raisons historiques : avant Angular 18.1, le template ne savait pas créer de variable.

@let règle ça. Tu déclares une variable directement dans le template, scoppée au bloc dans lequel elle est définie, avec le type narrowing qui va bien. Disponible en preview depuis la v18.1, stable depuis la v19. Voici 5 patterns concrets pour nettoyer ton code dès maintenant.

Valide Angular 18.1+ en preview, 19+ en stable.


TL;DR

Pattern Avant Après
Aliasing d'un signal call computed() ou méthode @let u = user();
Sous-propriété profonde chemins répétés @let stats = profile().stats;
Type narrowing post-@if ! ou as non-null @let total = u.price * u.qty;
Calcul dans @for computed() ou map en TS @let total = item.price * item.qty;
Composition d'expressions logique inline illisible @let label = ...;

La règle d'or : si l'expression n'a de sens que pour l'affichage et n'est pas réutilisée ailleurs dans la class, garde-la dans le template.


Le problème : le computed() de présentation

Voici un cas typique :

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { User } from './user';

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h2>{{ fullName() }}</h2>
    <p>{{ initials() }}</p>
    <a [href]="profileUrl()">{{ profileUrl() }}</a>
  `,
})
export class UserCard {
  user = input.required<User>();

  fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
  initials = computed(() => `${this.user().firstName[0]}${this.user().lastName[0]}`);
  profileUrl = computed(() => `/u/${this.user().username}`);
}

Trois computed() qui n'existent que pour le template. Aucun n'est testé en isolation, aucun n'est consommé ailleurs. Chaque appel relit this.user(), recrée des dépendances, et alourdit la class pour rien.

Avec @let, tu écris :

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { User } from './user';

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @let u = user();
    <h2>{{ u.firstName }} {{ u.lastName }}</h2>
    <p>{{ u.firstName[0] }}{{ u.lastName[0] }}</p>
    <a [href]="'/u/' + u.username">/u/{{ u.username }}</a>
  `,
})
export class UserCard {
  user = input.required<User>();
}

Une class avec une seule responsabilité : recevoir l'input. Zéro computed() de présentation. Et un seul accès à user() au lieu de six. Maintenant les patterns.


Pattern 1. Alias d'un signal pour éviter les appels répétés

Le piège classique : tu utilises data() cinq fois dans ton template parce que c'est plus lisible que de stocker dans un computed(). Sauf qu'à chaque appel, Angular relit le signal et trace la dépendance.

Avant

<h2>{{ data().title }}</h2>
<p>{{ data().description }}</p>
<ul>
  @for (tag of data().tags; track tag.id) {
    <li>{{ tag.label }}</li>
  }
</ul>
<small>{{ data().updatedAt | date }}</small>

Après

@let d = data();
<h2>{{ d.title }}</h2>
<p>{{ d.description }}</p>
<ul>
  @for (tag of d.tags; track tag.id) {
    <li>{{ tag.label }}</li>
  }
</ul>
<small>{{ d.updatedAt | date }}</small>

Un seul accès au signal. Le template est plus court, plus rapide à parcourir, et la dépendance est tracée une seule fois.

@let re-évalue son expression à chaque cycle de change detection. Tu ne gagnes pas en complexité algorithmique, tu gagnes en lisibilité et tu évites les appels multiples au getter de signal (ce qui compte quand tu chaînes des computed()).

Tip nommage : préfixe _ pour garder le même nom

Tu n'es pas obligé d'inventer un alias court (u, d, a) qui force à faire de la traduction mentale. Préfixe avec _ pour garder le sens et marquer visuellement la version "déréférencée du signal".

@let _user = user();
@let _article = article();

<h2>{{ _user.firstName }}</h2>
<p>Auteur de : {{ _article.title }}</p>

Le _ joue le rôle d'un marqueur conventionnel : "valeur extraite, pas le signal lui-même". Pratique pour relire en review : si tu vois user(), c'est le signal ; si tu vois _user, c'est la valeur courante. Aucun conflit de nom, aucune sémantique perdue.


Pattern 2. Décomposer un objet profond

Tu travailles avec un objet imbriqué (form value, réponse d'API, modèle complexe). Sans @let, tu te tapes form.controls.address.value.street partout.

Avant

<div>
  <label>{{ form.controls.address.value?.street }}</label>
  <label>{{ form.controls.address.value?.city }}</label>
  <label>{{ form.controls.address.value?.zip }}</label>
</div>

Après

@let address = form.controls.address.value;
<div>
  <label>{{ address?.street }}</label>
  <label>{{ address?.city }}</label>
  <label>{{ address?.zip }}</label>
</div>

Bonus : un seul endroit à modifier si la structure change.


Pattern 3. Type narrowing après un @if

Combiné avec @if, @let permet de dériver plusieurs variables après le narrowing du compilateur, sans passer par un ! qui ment au type system ni te limiter au seul as du bloc.

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { User } from './user';

@Component({
  selector: 'app-dashboard',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (currentUser(); as u) {
      @let isPremium = u.tier === 'premium';
      @let label = isPremium ? 'Membre Premium' : 'Membre Standard';
      @let badgeClass = isPremium ? 'badge-premium' : 'badge-standard';

      <h2>{{ u.name }}</h2>
      <p>Solde : {{ u.balance | currency }}</p>
      <span class="badge" [class]="badgeClass">{{ label }}</span>
    }
  `,
})
export class Dashboard {
  currentUser = input<User | null>(null);
}

isPremium, label et badgeClass sont scoppés au bloc @if. Ils n'existent pas en dehors, ne polluent pas le reste du template, et bénéficient du narrowing de u. Auparavant, tu aurais dû soit créer trois computed() qui répètent eux-mêmes le narrowing, soit te taper trois ternaires inline dans le template.


Pattern 4. Calcul mutualisé dans une boucle @for

C'est probablement le cas où @let change le plus la donne. Avant, pour calculer un total ligne par ligne dans une table, tu avais le choix entre :

  • Une méthode dans la class qui prend l'item en paramètre.
  • Une pré-projection en computed() qui mappe tout le tableau et duplique la donnée en mémoire.
  • Un calcul inline dans plusieurs interpolations.

Avant

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CartItem } from './cart-item';

@Component({
  selector: 'app-cart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <table>
      @for (item of items(); track item.id) {
        <tr>
          <td>{{ item.label }}</td>
          <td>{{ item.price | currency }}</td>
          <td>{{ item.quantity }}</td>
          <td>{{ computeTotal(item) | currency }}</td>
        </tr>
      }
    </table>
  `,
})
export class Cart {
  items = input.required<CartItem[]>();

  computeTotal(item: CartItem): number {
    return item.price * item.quantity;
  }
}

Après

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CartItem } from './cart-item';

@Component({
  selector: 'app-cart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <table>
      @for (item of items(); track item.id) {
        @let total = item.price * item.quantity;
        <tr>
          <td>{{ item.label }}</td>
          <td>{{ item.price | currency }}</td>
          <td>{{ item.quantity }}</td>
          <td>{{ total | currency }}</td>
        </tr>
      }
    </table>
  `,
})
export class Cart {
  items = input.required<CartItem[]>();
}

total est local à chaque itération du @for. Pas de méthode, pas de computed(), pas de noise dans la class. Si demain tu ajoutes une remise par ligne, tu modifies une seule expression dans le template.

Le bonus subtil : la méthode computeTotal() aurait été ré-appelée pour chaque cellule à chaque cycle (même avec OnPush). Avec @let, l'expression est évaluée une fois par itération, point.


Pattern 5. Composition d'expressions pour les libellés et URLs

Pour les libellés dynamiques (placeholders, aria-labels, URLs construites à la volée), @let permet de découper une expression en étapes lisibles.

Avant

<a
  [href]="'/articles/' + article().slug + '?ref=card-' + article().category"
  [attr.aria-label]="'Lire l\'article ' + article().title + ' publié le ' + (article().publishedAt | date)"
>
  {{ article().title }}
</a>

Après

@let a = article();
@let href = '/articles/' + a.slug + '?ref=card-' + a.category;
@let aria = 'Lire l\'article ' + a.title + ' publié le ' + (a.publishedAt | date);

<a [href]="href" [attr.aria-label]="aria">{{ a.title }}</a>

Tu lis le template comme une recette, pas comme un sudoku. Et si tu dois debug ton aria-label, tu sais exactement où regarder.


4 pièges à connaître

1. Le scope est strict

@if (data(); as d) {
  @let label = d.name;
}
<!-- label n'existe plus ici : erreur de compilation -->
<span>{{ label }}</span>

@let est scoppé au bloc qui le contient. Tu ne peux pas l'utiliser hors de ce bloc. C'est intentionnel : ça empêche les variables fantômes qui survivraient à leur condition.

2. Pas de réassignation

@let count = items().length;
@let count = count + 1; <!-- erreur de compilation -->

@let est immuable dans son scope. Tu peux redéclarer un nom identique dans un sous-scope (shadowing), pas le réassigner.

3. Évalué à chaque change detection

@let now = Date.now();
<p>{{ now }}</p>

Cette ligne met à jour now à chaque cycle. Évite les expressions à effet de bord, les appels coûteux (parse JSON, regex lourdes), ou les valeurs non-déterministes que tu veux figer.

Pour figer une valeur au montage, garde un computed() ou un signal() initialisé dans la class.

4. Pas un remplaçant de computed() pour les vraies dérivations

Si une valeur est consommée à la fois dans le template et dans une méthode (ou un autre composant via output, ou un test unitaire), garde un computed(). @let est une optimisation de présentation, pas un remplaçant générique de signal dérivé.

Règle de décision rapide :

  • L'expression sort du template (test, méthode, autre composant) → computed().
  • L'expression vit et meurt dans le template → @let.

5. Garde le template léger, ne le transforme pas en mini-composant

@let rend tellement de choses possibles dans le template que la tentation est forte d'y caser de la logique. Résiste. Un template doit rester de la présentation : des expressions courtes, lisibles d'un coup d'œil, sans branchements imbriqués ni calculs lourds.

<!-- Non. Trop de logique, plus testable du tout. -->
@let score = items().reduce((acc, i) => acc + (i.active ? i.value * i.weight : 0), 0);
@let level = score > 100 ? 'gold' : score > 50 ? 'silver' : score > 10 ? 'bronze' : 'none';
@let label = level === 'none' ? 'Pas encore qualifié' : `Niveau ${level} (${score} pts)`;

Dès que ton @let contient un reduce, une chaîne de ternaires, ou une expression qui demande à être commentée pour être comprise, remonte dans la class. Crée un computed() (ou plusieurs), testable, nommé proprement, et appelle-le depuis le template. Le template gagne sa simplicité, et le code testable retrouve sa place.

Règle pratique : si tu hésites à écrire ton expression sur une seule ligne lisible, c'est qu'elle n'a rien à faire dans un @let.


Récap actionnable

À chaque computed() que tu écris, pose-toi trois questions :

  1. Est-ce que ce dérivé est utilisé ailleurs que dans le template ? Non → candidat @let.
  2. Est-ce que je passe par une méthode du composant juste pour formater dans le template ? Oui → candidat @let.
  3. Est-ce que je répète signal().subProperty plus de deux fois ? Oui → candidat @let.

Cible concrète sur ton prochain refacto : sors tes plus gros composants (200+ lignes), compte les computed() consommés uniquement par le template, et migre-les. Tu vas voir des classes fondre de 30%, des templates qui se lisent enfin de haut en bas, et des reviewers qui arrêtent de te demander "à quoi sert ce computed() exactement ?".

@let ne révolutionne rien sur le plan algorithmique. Mais c'est un de ces outils qui, une fois adoptés, te font détester relire ton ancien code. Et c'est exactement le signe que ton outillage évolue dans le bon sens.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.