Front-end angularforall.com

- Angular 19 : linkedSignal et resource API

Angular Angular 19 Signals Linkedsignal Resource
Angular 19 : linkedSignal et resource API

Découvrez linkedSignal et la resource API d'Angular 19 : gérez les dépendances entre Signals et les appels HTTP asynchrones de façon réactive et efficace.

linkedSignal() : signaux dérivés modifiables

Angular 19 introduit linkedSignal(), un nouveau type de signal qui combine les propriétés de signal() (modifiable par .set() et .update()) et de computed() (dérivé automatiquement d'une source). Il répond au cas précis où un état doit être initialisé et réinitialisé depuis une source, tout en pouvant être modifié localement par l'utilisateur.

Différence avec computed()

computed() linkedSignal() signal()
Dérivé d'une source Oui Oui Non
Modifiable (.set) Non Oui Oui
Réinitialisation auto Toujours recalculé Sur changement de source Non
Cas d'usage Valeur dérivée pure Sélection/filtre local État indépendant

Exemple : sélection de produit avec reset automatique

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

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

@Component({
  selector: 'app-product-select',
  standalone: true,
  template: `
    <!-- Changer la catégorie recharge les produits ET réinitialise la sélection -->
    <select (change)="category.set($event.target.value)">
      <option value="frontend">Front-end</option>
      <option value="backend">Back-end</option>
    </select>

    <select (change)="selectedId.set(+$event.target.value)">
      @for (p of products(); track p.id) {
        <option [value]="p.id" [selected]="p.id === selectedId()">
          {{ p.name }}
        </option>
      }
    </select>

    <p>Sélectionné : {{ selectedId() }}</p>
  `
})
export class ProductSelectComponent {
  category = signal('frontend');

  // Recalculé quand category change
  products = computed(() =>
    this.category() === 'frontend'
      ? [{ id: 1, name: 'Angular' }, { id: 2, name: 'React' }]
      : [{ id: 3, name: 'Node.js' }, { id: 4, name: 'FastAPI' }]
  );

  // Se réinitialise à products()[0].id quand products() change
  // Mais l'utilisateur peut aussi le modifier manuellement
  selectedId = linkedSignal(() => this.products()[0].id);
}
Comportement clé : quand category change → products est recalculé → selectedId se réinitialise automatiquement au premier produit de la nouvelle liste. L'utilisateur peut ensuite choisir un autre produit sans que ça ne recharge la liste.

Options avancées et patterns courants

La forme longue de linkedSignal() permet de contrôler précisément comment la réinitialisation est calculée, en accédant à la valeur précédente via le paramètre previous.

Conserver la sélection si elle est toujours valide

// Cas réel: pagination — conserver le numéro de page si les filtres changent
// mais seulement si la page existe encore dans les nouveaux résultats
currentPage = linkedSignal({
  source: this.totalPages,          // signal du nombre total de pages
  computation: (totalPages, previous) => {
    const prevPage = previous?.value ?? 1;
    // Si la page actuelle est toujours valide, la conserver
    return prevPage <= totalPages ? prevPage : 1;
  },
});

// Autre cas: conserver la sélection si l'item existe encore dans la liste
selectedUserId = linkedSignal({
  source: this.filteredUsers,
  computation: (users, previous) => {
    const previousId = previous?.value;
    const stillExists = users.some(u => u.id === previousId);
    return stillExists ? previousId! : users[0]?.id ?? null;
  },
});

Pattern: filtres avec reset de pagination

// Pattern très courant: quand le filtre change, on revient à la page 1
@Component({ ... })
export class UserListComponent {
  searchTerm = signal('');
  sortBy = signal('name');

  // La page se réinitialise à 1 quand searchTerm ou sortBy change
  currentPage = linkedSignal(() => {
    this.searchTerm(); // dépendance: trackée automatiquement
    this.sortBy();     // dépendance: trackée automatiquement
    return 1;
  });

  // L'utilisateur peut aussi changer la page manuellement
  // page.set(3) fonctionne tant que searchTerm/sortBy ne change pas

  filteredUsers = computed(() => {
    const term = this.searchTerm().toLowerCase();
    const page = this.currentPage();
    const users = this.allUsers().filter(u => u.name.toLowerCase().includes(term));
    return users.slice((page - 1) * 20, page * 20);
  });
}

La resource() API

La resource() API (stable depuis Angular 19.2) est une abstraction pour gérer les données asynchrones dans les composants Angular. Elle modélise le cycle complet d'une requête: déclenchement, chargement, succès, erreur et rechargement — sans écrire de RxJS.

Structure complète d'une resource

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

interface User { id: number; name: string; email: string; }

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @switch (userResource.status()) {
      @case ('loading') {
        <div class="spinner">Chargement...</div>
      }
      @case ('error') {
        <div class="alert alert-danger">
          Erreur : {{ userResource.error()?.message }}
          <button (click)="userResource.reload()">Réessayer</button>
        </div>
      }
      @case ('resolved') {
        <div>
          <h2>{{ userResource.value()?.name }}</h2>
          <p>{{ userResource.value()?.email }}</p>
          <button (click)="refresh()">Rafraîchir</button>
        </div>
      }
    }
  `
})
export class UserProfileComponent {
  userId = signal(1);

  userResource = resource<User, { id: number }>({
    // La "request" définit les paramètres — quand elle change, le loader se relance
    request: () => ({ id: this.userId() }),

    // Le "loader" est la fonction async qui charge les données
    loader: async ({ request, abortSignal }) => {
      // abortSignal annule la requête si une nouvelle request arrive
      const response = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      return response.json() as Promise<User>;
    },
  });

  // Forcer un rechargement sans changer la request
  refresh() {
    this.userResource.reload();
  }
}

États et gestion des erreurs

Une resource expose un signal status() avec 6 états distincts, plus des raccourcis:

Status Description value() isLoading()
idle Jamais chargé (request === undefined) undefined false
loading Premier chargement en cours undefined true
refreshing Rechargement (valeur précédente disponible) ancienne valeur true
resolved Données chargées avec succès données false
error Échec du chargement undefined false
local Valeur définie manuellement via .set() valeur locale false
const r = resource({ ... });

// Signaux d'état
r.value()      // T | undefined
r.status()     // ResourceStatus (string union)
r.isLoading()  // boolean — true pendant loading ET refreshing
r.error()      // unknown — l'erreur si status === 'error'

// Actions
r.reload();         // force un rechargement (status → 'loading' ou 'refreshing')
r.set(newValue);    // modifie la valeur localement (status → 'local')
r.update(fn);       // met à jour basé sur la valeur actuelle

// Retry manuel avec délai exponentiel
async function retryResource(r: ResourceRef<any>, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    if (r.status() === 'error') {
      await new Promise(res => setTimeout(res, Math.pow(2, i) * 1000));
      r.reload();
    } else break;
  }
}

httpResource() et types génériques

httpResource() est une version de resource() optimisée pour HttpClient. Elle annule automatiquement les requêtes en cours quand la request change (via AbortController), supporte le typage générique complet et s'intègre avec les intercepteurs HTTP existants.

import { Component, signal, computed } from '@angular/core';
import { httpResource } from '@angular/common/http';

interface Post { id: number; title: string; body: string; userId: number; }
interface PaginatedResponse<T> { data: T[]; total: number; page: number; }

@Component({
  selector: 'app-posts',
  standalone: true,
  template: `
    <div class="filters">
      <input [value]="search()" (input)="search.set($event.target.value)"
             placeholder="Rechercher...">
      <select (change)="perPage.set(+$event.target.value)">
        <option value="10">10 par page</option>
        <option value="25">25 par page</option>
      </select>
    </div>

    @if (postsResource.isLoading()) {
      <p>Chargement...</p>
    }

    @for (post of postsResource.value()?.data ?? []; track post.id) {
      <article><h3>{{ post.title }}</h3></article>
    }

    <p>Total: {{ postsResource.value()?.total ?? 0 }} articles</p>
  `
})
export class PostsComponent {
  search = signal('');
  page = linkedSignal(() => { this.search(); return 1; }); // reset sur search
  perPage = signal(10);

  // httpResource typé: PaginatedResponse<Post>
  postsResource = httpResource<PaginatedResponse<Post>>(() => {
    const params = new URLSearchParams({
      q: this.search(),
      page: String(this.page()),
      limit: String(this.perPage()),
    });
    return `/api/posts?${params}`;
  });

  // Dériver des données calculées depuis la resource
  totalPages = computed(() =>
    Math.ceil((this.postsResource.value()?.total ?? 0) / this.perPage())
  );
}

httpResource avec options avancées

// httpResource avec headers, méthode HTTP et transformation
posts = httpResource({
  url: () => '/api/posts',
  method: 'GET',
  headers: () => ({ Authorization: `Bearer ${this.token()}` }),
  // Transformer la réponse avant stockage
  responseType: 'json',
});

// Accès aux headers de réponse
posts = httpResource(() => ({
  url: `/api/data`,
  observe: 'response', // retourne HttpResponse<T> complet
}));

Mises à jour optimistes avec resource

Le status local de resource permet des mises à jour optimistes: mettre à jour l'UI immédiatement (r.set()) puis synchroniser avec le serveur en arrière-plan, avec rollback en cas d'erreur.

@Component({ ... })
export class TodoListComponent {
  todos = httpResource<Todo[]>(() => '/api/todos');

  async toggleTodo(id: number) {
    // 1. Mise à jour optimiste immédiate (status → 'local')
    this.todos.update(todos =>
      todos?.map(t => t.id === id ? { ...t, done: !t.done } : t) ?? []
    );

    // Sauvegarder l'état pour le rollback
    const previousTodos = this.todos.value();

    try {
      // 2. Synchronisation avec le serveur
      await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
      // 3. Rechargement depuis le serveur pour confirmer
      this.todos.reload();
    } catch (error) {
      // 4. Rollback en cas d'erreur serveur
      this.todos.set(previousTodos ?? []);
      console.error('Erreur lors du toggle:', error);
    }
  }
}

Comparaison: resource vs RxJS vs async pipe

Aspect resource() / httpResource() RxJS (switchMap) async pipe
Gestion des états Intégrée (6 états) Manuelle (BehaviorSubject) Partielle (loading non inclus)
Annulation Automatique (AbortController) switchMap takeUntilDestroyed
Retry .reload() manuel retry(), retryWhen() N/A
Mise à jour optimiste .set() / .update() Complexe (startWith) Non
Typage Générique complet Observable<T> Observable<T>
Complexité Faible Élevée Moyenne
// ===== Avant Angular 19: avec RxJS =====
userId$ = new BehaviorSubject(1);
isLoading$ = new BehaviorSubject(false);
error$ = new BehaviorSubject<Error | null>(null);

user$ = this.userId$.pipe(
  tap(() => this.isLoading$.next(true)),
  switchMap(id => this.http.get<User>(`/api/users/${id}`).pipe(
    catchError(err => {
      this.error$.next(err);
      return EMPTY;
    }),
    finalize(() => this.isLoading$.next(false))
  )),
  shareReplay(1)
);

// ===== Angular 19+: avec httpResource() =====
userId = signal(1);
// isLoading, error, value: tous inclus dans la resource
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
Recommandation 2026 : utilisez httpResource() pour les cas "fetch quand un signal change". Gardez RxJS pour les streams d'événements complexes, les combinaisons multi-sources (combineLatest, forkJoin), et les transformations avancées. Les deux coexistent parfaitement dans une même application.

Partager