📧 Reste informé(e) !

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

S'inscrire gratuitement

~9 min de lecture

linkedSignal({ set }) : Angular 22.1 branche enfin l'écriture sur la source

Tu as un objet order dans un store. Tu veux exposer sa méthode de livraison dans un <select>. Réflexe signals correct : linkedSignal(), parce qu'il te faut une valeur dérivée de order mais modifiable par l'utilisateur.

protected readonly shippingMethod = linkedSignal(() => this.order().shippingMethod);

L'utilisateur choisit "Aérien". Le <select> affiche "Aérien". Tu passes à l'écran suivant, tu reviens, et la commande est toujours en livraison terrestre. Ton écriture n'est jamais arrivée jusqu'à order.

Ce n'est pas un bug. C'est le contrat de linkedSignal() depuis Angular 19 : l'écriture est locale, elle vit jusqu'au prochain recalcul et disparaît avec lui. Angular 22.1 ajoute l'option set, qui casse enfin cette asymétrie.


TL;DR

Le cas Ce qui se passe
linkedSignal(() => src()), sans option Tu écris, la valeur saute au prochain recalcul
linkedSignal(() => src(), { set }) Ton écriture part où le callback set l'envoie
Un set qui n'écrit nulle part L'écriture disparaît, sans erreur ni warning
rawSet(value) dans le callback (la porte de sortie) Écriture locale, comme avant la 22.1
Ton set ne défait pas ce que ta fonction de calcul a fait Tu écris 5, tu relis 6

Le comportement historique, en un test

Voici ce que fait un linkedSignal() sans option. Ce test passe au vert sur Angular 22.1 comme sur Angular 19 :

it('garde la valeur ecrite jusqu au prochain recalcul, puis la perd', () => {
  const orderTotal = signal(100);
  const discounted = linkedSignal(() => orderTotal() * 0.9);

  expect(discounted()).toBe(90);

  discounted.set(50);
  expect(discounted()).toBe(50);
  expect(orderTotal()).toBe(100); // la source n a pas bouge

  orderTotal.set(200);
  expect(discounted()).toBe(180); // l ecriture de 50 est perdue
});

Deux choses à retenir de ces quatre assertions. D'abord, discounted.set(50) ne touche jamais orderTotal : la source ignore l'écriture. Ensuite, la valeur 50 survit jusqu'au moment où la source bouge, et elle est écrasée sans avertissement.

C'est le comportement voulu pour le cas d'usage d'origine : une sélection qui se réinitialise quand le filtre change, une pagination qui revient à la page 1, deux patterns détaillés dans le guide complet de linkedSignal(). Mais dès que tu veux un aller-retour, ce contrat te trahit en silence.


L'option set : la signature

Angular 22.1 ajoute une propriété aux options de linkedSignal(), à côté de equal et debugName. Voici la déclaration exacte, telle qu'elle apparaît dans les types publiés du paquet :

set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;

D, c'est le type de la valeur dérivée, celle que renvoie ta fonction de calcul. NoInfer empêche TypeScript de déduire ce type depuis l'argument de set : c'est le calcul qui fixe le type, pas l'écriture.

Quand tu fournis ce callback, Angular ne fait plus rien lui-même au moment d'un set() : il te passe la valeur demandée et te laisse décider où elle va. Une conversion Celsius / Fahrenheit dans les deux sens tient en trois lignes :

const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
  set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});

tempF.set(212);
// tempC() === 100, tempF() === 212

tempF.set(212) écrit 100 dans tempC, le calcul se relance et redonne 212. Les deux signaux restent cohérents sans un seul effect().


Les quatre règles de set

Le contrat tient en quatre points, dont trois contre-intuitifs. Les assertions qui suivent tournent toutes au vert sur Angular 22.1.0.

1. Ton callback remplace l'écriture, il ne s'y ajoute pas

C'est le point le plus important, et le plus facile à rater : si ton callback n'écrit nulle part, l'écriture est avalée en silence. Pas d'erreur, pas de warning, la valeur ne bouge pas.

const source = signal(1);
const mirror = linkedSignal(() => source(), { set: () => {} });

mirror.set(99);

expect(mirror()).toBe(1);   // pas 99
expect(source()).toBe(1);

Une garde mal placée (if (!isValid) return;) et tu as un champ de formulaire qui refuse les saisies sans le dire. Si ton set peut refuser une valeur, rends le refus visible : lève une exception, logue, ou écris dans un signal d'erreur.

2. rawSet te rend l'ancien comportement

Le deuxième argument du callback écrit dans le linkedSignal lui-même, exactement comme avant la 22.1. Donc valeur locale, écrasée au prochain recalcul.

const source = signal(10);
const mirror = linkedSignal(() => source(), {
  set: (value, rawSet) => rawSet(value),
});

mirror.set(5);
expect(mirror()).toBe(5);
expect(source()).toBe(10);  // la source reste intacte

source.set(11);
expect(mirror()).toBe(11);  // le recalcul reprend la main

L'intérêt est de pouvoir router conditionnellement. Un champ qui ne remonte au modèle qu'une fois la valeur valide, et garde la frappe en cours en local le reste du temps, tient en un callback :

const committed = signal('');
const draft = linkedSignal(() => committed(), {
  set: (value, rawSet) => {
    if (value.length >= 3) {
      committed.set(value);
    } else {
      rawSet(value);
    }
  },
});

draft.set('ab');
expect(draft()).toBe('ab');
expect(committed()).toBe('');   // trop court, reste local

draft.set('abc');
expect(committed()).toBe('abc'); // valide, remonte a la source

Cette logique demandait auparavant deux signaux et un effect() de synchronisation. Ici, la règle de propagation vit au même endroit que la dérivation.

3. update() passe aussi par set

Pas de chemin dérobé. update() lit la valeur courante, applique ta fonction, et passe le résultat à ton callback.

const count = signal(1);
const doubled = linkedSignal(() => count() * 2, {
  set: (value) => count.set(value / 2),
});

doubled.update((current) => current + 2);

expect(count()).toBe(2);
expect(doubled()).toBe(4);

4. set marche aussi sur la forme longue

L'option est acceptée sur les deux formes : la forme courte, et la forme longue { source, computation }. Le callback y reçoit les deux mêmes arguments.

const user = signal({ id: 1, displayName: 'Ada' });

const displayName = linkedSignal({
  source: user,
  computation: (source) => source.displayName,
  set: (name) => user.update((current) => ({ ...current, displayName: name })),
});

displayName.set('Grace');

expect(user().displayName).toBe('Grace');
expect(displayName()).toBe('Grace');

Le piège : ton set doit être l'inverse exact de ta fonction de calcul

Rien ne vérifie que ton set défait ce que ta fonction de calcul a fait. Si les deux ne se répondent pas exactement, tu écris une valeur et tu en relis une autre.

const page = signal(0);                                  // index 0-based
const displayedPage = linkedSignal(() => page() + 1, {   // affichage 1-based
  set: (value) => page.set(value),                       // oubli du -1
});

displayedPage.set(5);

expect(page()).toBe(5);
expect(displayedPage()).toBe(6);  // pas 5

Tu demandes la page 5, tu obtiens la 6. Sur un décalage d'index, c'est visible tout de suite ; sur un arrondi monétaire, ça passe en production. La règle est mécanique : pour toute valeur v, set(v) suivi d'une lecture doit redonner v. Cet invariant s'écrit en une assertion, alors écris-la.


En composant : l'enfant édite une propriété, le parent reçoit l'objet

Le cas réel qui motive tout ça : un composant enfant qui reçoit un objet via model() et n'expose qu'une propriété à l'édition, sans muter l'objet parent.

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

export type Order = { id: number; shippingMethod: 'Ground' | 'Air' };

@Component({
  selector: 'app-shipping-picker',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <select [value]="shippingMethod()" (change)="shippingMethod.set(readMethod($event))">
      <option value="Ground">Terrestre</option>
      <option value="Air">Aérien</option>
    </select>

    <p>Livraison : {{ shippingMethod() }}</p>
  `,
})
export class ShippingPicker {
  readonly order = model.required<Order>();

  protected readonly shippingMethod = linkedSignal(() => this.order().shippingMethod, {
    set: (method) => this.order.update((current) => ({ ...current, shippingMethod: method })),
  });

  protected readMethod(event: Event): Order['shippingMethod'] {
    return (event.target as HTMLSelectElement).value as Order['shippingMethod'];
  }
}

Le set remonte jusqu'au parent via model(), en recréant l'objet plutôt qu'en le mutant. Les deux sens fonctionnent :

it('remonte la modification du select jusqu au model order', async () => {
  const fixture = TestBed.createComponent(ShippingPicker);
  fixture.componentRef.setInput('order', { id: 42, shippingMethod: 'Ground' } satisfies Order);
  await fixture.whenStable();

  const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
  select.value = 'Air';
  select.dispatchEvent(new Event('change'));
  await fixture.whenStable();

  expect(fixture.componentInstance.order()).toEqual({ id: 42, shippingMethod: 'Air' });
});

Avant la 22.1, ce composant demandait soit un output() et une remontée manuelle, soit un effect() qui surveille le signal local pour repousser vers le parent. Ce second montage est précisément l'anti-pattern que linkedSignal() était censé supprimer.


Quand ne pas utiliser set

L'option est neuve, donc elle va être sur-utilisée. Trois cas où elle n'est pas la bonne réponse.

Ton calcul n'est pas inversible. Si ta dérivation agrège, filtre ou réduit (items().length, une somme, un find()), il n'existe pas de valeur source unique à réécrire : un set ne peut être qu'une devinette. Reste sur computed().

Tu exposes toute la valeur. Si l'enfant édite exactement la donnée que le parent lui passe, model() seul suffit. linkedSignal({ set }) ne se justifie que quand la valeur exposée est une projection de la source : une propriété d'un objet, une conversion d'unité, un format d'affichage.

La source est un store. L'écriture doit passer par une méthode du store. L'appeler depuis le callback est très bien ; faire du patchState (NgRx Signal Store) en douce depuis un composant, non.


Le reste d'Angular 22.1

Cache de transfert SSR : deux verrous deviennent ouvrables

HttpTransferCacheOptions gagne includeRequestsWithCredentials et includeNonCacheableRequests.

Contexte : le cache de transfert s'est verrouillé au fil de la série 22.0. Depuis la 22.0.2, il exclut d'office les requêtes dont le mode credentials vaut include ou same-origin (le withCredentials classique, lui, était déjà exclu), celles qui portent un Cache-Control: no-store, no-cache ou private, et celles dont le mode cache vaut no-store ou no-cache ; depuis la 22.0.3, les réponses qui portent un Set-Cookie. Ces exclusions étaient sans recours : l'option filter ne peut que restreindre la liste, jamais y réintégrer une requête exclue, comme expliqué dans les 4 erreurs qui font fetcher ton API deux fois. La 22.1 ajoute l'opt-in qui manquait : la correction côté backend que cet article recommandait reste la plus sûre, mais elle n'est plus la seule issue.

Ne confonds pas avec includeRequestsWithAuthHeaders, plus ancienne, qui couvre Authorization, Proxy-Authorization et Cookie : ce n'est pas elle que la 22.1 débloque.

provideClientHydration(
  withHttpTransferCacheOptions({
    includeRequestsWithCredentials: true,
    includeNonCacheableRequests: true,
  }),
)

À manier avec prudence : ces réponses sont sérialisées en clair dans le HTML envoyé au navigateur. Un Cache-Control: private sur un endpoint de profil est ce qui empêche la donnée d'un utilisateur de finir dans le HTML servi à un autre. N'ouvre ces options que sur des endpoints dont tu sais ce qu'ils renvoient, et à qui.

Migrer @Injectable vers @Service : le schematic est là

Le décorateur @Service de la 22.0 a maintenant sa migration automatique :

ng generate @angular/core:service-migration

Sur un @Injectable({ providedIn: 'root' }), il produit @Service(). Sur un @Injectable() nu, @Service({ autoProvided: false }), qui préserve le comportement : le service reste à fournir explicitement. L'import est nettoyé au passage. La 22.1 n'apporte que l'outil de migration, pas un remplacement : pour savoir quand @Injectable reste le bon choix, va voir @Service ou @Injectable.

JSONP est déprécié

HttpClient.jsonp, HttpClientJsonpModule et les classes associées sont dépréciés en 22.1. Le message de dépréciation donne le motif : JSONP ouvre la porte à des failles XSS. Le retrait est annoncé sans calendrier : c'est une dette, pas une urgence.

Isoler les variables CSS par application

Utile si tu embarques plusieurs apps Angular dans la même page : leurs variables CSS peuvent enfin être cloisonnées. provideCssVarNamespacing(), exporté par @angular/platform-browser, les préfixe avec le namespace que tu passes, ou par défaut avec l'APP_ID de l'application, le token Angular qui l'identifie (ng si tu n'y as jamais touché).

providers: [provideCssVarNamespacing('eak')]

Une variable --accent déclarée dans un composant est alors rendue --eak_accent, références comprises. Pour en soustraire une au préfixage, il faut deux tirets après global : --global--brand ressort en --brand. Attention au piège : --global-brand, avec un seul tiret, n'est pas une échappatoire, c'est une variable ordinaire qui sera préfixée. Angular prévoit une erreur de compilation pour cette faute de frappe, mais elle n'est active que dans le monorepo interne de Google.

Le préfixe --global-- marche aussi côté lecture : il dit de ne pas préfixer ce nom-là. Et c'est là qu'est le piège d'adoption, parce que styles.css n'est pas préfixé. Ton :root { --brand: ... } global reste --brand, mais un composant qui écrit var(--brand) cherche désormais --eak_brand, qui n'existe pas. Aucune erreur, juste un style qui ne s'applique plus. Si tes tokens de design system vivent dans une feuille globale, chaque var() écrit dans un composant doit passer en var(--global--brand).

À connaître même sans opt-in : le compilateur réécrit les variables CSS de composant en --%NS%nom dans le bundle, sauf celles échappées par --global--. %NS% est remplacé au rendu, par une chaîne vide si tu n'actives rien. Un test qui lit le CSS émis n'y trouvera plus tes noms de variables.

Build : l'optimisation de chunks passe à Rolldown

Côté @angular/build, l'étape qui regroupe les fichiers de sortie bascule sur Rolldown par défaut, et s'étend aux builds serveur, qui en étaient exclus. Les plugins Babel d'optimisation avancée s'appuient sur oxc-parser, un parser JavaScript écrit en Rust. Le cache de build persistant est désormais partagé entre worktrees git : deux branches ouvertes en parallèle ne recompilent plus chacune de leur côté. Le changelog ne chiffre aucun de ces gains : si le temps de build est un sujet chez toi, mesure avant et après.


Récap actionnable

Ce que tu veux Ce que tu écris
Valeur dérivée en lecture seule computed()
Valeur dérivée, modifiable, reset au changement de source linkedSignal(() => ...)
Valeur dérivée dont l'écriture doit remonter à la source linkedSignal(..., { set })
Écriture locale conditionnelle rawSet dans le callback set

Avant de pousser ton premier set en production :

  1. Un set qui n'écrit nulle part avale l'écriture sans rien dire. Si ton callback peut refuser une valeur, rends le refus observable.
  2. Teste l'aller-retour. set(v) puis lecture doit redonner v.
  3. update() passe par set. Ton callback doit encaisser toutes les valeurs, pas seulement celles d'un set() explicite.

Et si tu es encore en 22.0, la mise à jour est un ng update @angular/core @angular/cli : la 22.1 n'introduit aucun breaking change, seulement des options en plus et deux dépréciations à noter, dont JSONP.

📧 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.