TanStack Angular Queryを使う


TanStack QueryではAngularのサポートに向け、APIを提供するためのadapterの開発が進められています。

https://github.com/TanStack/query/discussions/6293

Reactの開発で使用した時の開発体験が良かったこともあり、Angularではどうなんだろうと気になって触ってみました。

:::note warn
Angular Query(`@tanstack/angular-query-experimental`)は名前の通り実験的機能です。
また、本記事はプロダクトでの利用を推奨するものではありません。
https://tanstack.com/query/latest/docs/angular/overview
:::

## Angularのプロジェクトに導入する

パッケージをインストールします。devtoolsはお好みで(本記事では割愛します)。

```terminal
% npm i @tanstack/angular-query-experimental
```

アプリケーションで `QueryClient` を使うため、 依存関係を設定します。 `defaultOptions` でデータフェッチやキャッシュコントロールの設定をします。デフォルト値なので、リクエスト単位で変更することが可能です。

```app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import {
  QueryClient,
  provideAngularQuery,
} from '@tanstack/angular-query-experimental';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    // 動きを見るだけなので、デフォルトでQueryClientConfigの使わないオプションを切っています。
    // https://tanstack.com/query/latest/docs/react/reference/useQuery
    provideAngularQuery(
      new QueryClient({
        defaultOptions: {
          queries: {
            retry: false,
            gcTime: 0,
            retryOnMount: false,
          },
          mutations: {
            retry: false,
            gcTime: 0,
          },
        },
      })
    ),
  ],
};
```

## Query, Mutationの実装

### injectQuery

`injectQuery` は、 `ngOnInit` のようなコンポーネントのライフサイクル内でメソッドを叩くのではなく、インスタンス生成のタイミングで `queryFn` のPromiseが実行されます。
以下のサンプルコードでは `queryFn` でユーザ一覧取得のリクエストを行います。
※サンプルコードでは意図的にServiceを使っていません。

```users.component.ts
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { firstValueFrom } from 'rxjs';

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

@Component({
  selector: 'app-users',
  standalone: true,
  imports: [HttpClientModule],
  styleUrl: './users.component.scss',
  template: `
    @if (query.data(); as users) {
      @for(user of users; track user.id) {
        <h1>{{ user.name }}</h1>
      }
    }
  `,
})
export class UsersComponent {
  private readonly http = inject(HttpClient);

  // ユーザ一覧取得のQuery
  readonly query = injectQuery(() => ({
    queryKey: ['users'],
    queryFn: () => firstValueFrom(this.http.get<User[]>('/users')),
  }));

  constructor(){}
}

```

### injectMutation

`injectMutation` は `injectQuery` とは異なり、任意のタイミングで `mutationFn` を実行します。`queryClient.setQueryData()` の引数に `injectQuery` と同じkeyを渡すことで、 `query.data()` の内容を更新することができます。
以下のサンプルコードではユーザ情報の更新を行います。

```user-detail.component.ts
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { Component, Signal, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import {
  QueryKey,
  injectMutation,
  injectQuery,
} from '@tanstack/angular-query-experimental';
import { firstValueFrom, map } from 'rxjs';
import { UserInfo } from '../user-info/user-info.component';

interface UserInfo {
  name: string;
  age: number;
  memo: string;
}

interface UpdateUserInfoResponse {
  name: string;
  age: number;
  memo: string;
  updatedAt: string;
}

@Component({
  selector: 'app-edit-user',
  standalone: true,
  imports: [HttpClientModule, UserInfoComponent],
  styleUrl: './users.component.scss',
  template: `
    @if (query.data(); as userInfo) {
      <!-- ユーザ情報の表示と更新の操作を行うコンポーネント -->
      <app-user-info [userInfo]="userInfo" (onUpdateButtonClick)="updateUserInfo($event)"></app-user-info>
    }
  `,
})
export class EditUserComponent {
  private readonly http = inject(HttpClient);
  // pathparamsからユーザIDを取得する
  private readonly id = toSignal(
    inject(ActivatedRoute).paramMap.pipe(map((paramMap) => paramMap.get('id')))
  );
  // ユーザIDのSignalsを依存に持つQueryKeyのSignalsを定義する
  private readonly queryKey: Signal<QueryKey> = computed(() => [
    'users',
    this.id(),
  ]);

  // ユーザ情報取得のQuery
  readonly query = injectQuery(() => ({
    queryKey: this.queryKey(),
    queryFn: () => firstValueFrom(this.http.get<UserInfo>(`/users/${this.id()}`)),
  }));

  // ユーザ情報更新のMutation
  readonly mutation = injectMutation((queryClient) => ({
    mutationFn: (data: UserInfo) => firstValueFrom(this.http.put<UpdateUserInfoResponse>(`/users/${this.id()}`, data)),
    // リクエスト成功時にUpdateUserInfoResponseの内容でquery.data()を更新
    onSuccess: ({ name, age, memo }) => {
      queryClient.setQueryData(this.queryKey(), { name, age, memo });
    },
  }));

  constructor(){}

  updateUserInfo(data: UserInfo) {
    // mutationFnのPromiseを実行する
    this.mutation().mutate(data);
  }
}

```

### injectIsFetching

`通信中はheaderにprogress barを表示する` のように、アプリケーション内で通信の監視が必要な場合、以下のように `injectIsFetching` を使うことで通信中の状態を取得することが可能です。

```progress-bar.component.ts
import { Component, Signal, computed } from '@angular/core';
import { injectIsFetching } from '@tanstack/angular-query-experimental';

@Component({
  selector: 'app-progress-bar',
  standalone: true,
  imports: [],
  styleUrl: './progress-bar.component.scss',
  template: `
    @if (isFetching) {
      <div class="progress-bar"></div>
    }
  `,
})
export class ProgressBarComponent {
  // injectIsFetchingの件数が0より大きいかどうかで判別する
  readonly isFetching: Signal<boolean> = computed(() => injectIsFetching()() > 0);

  constructor(){}
}

```

## まとめ

非同期処理の状態を `QueryClient + QueryKey` でユニークに管理でき、非同期処理(query)の結果を詰めるためのSignalsを別で作らずに `queryClient.setQueryData()` で更新できるのは魅力的です。APIをSignalsベースで使える点も、モダンなAngular開発との親和性が高いと感じます。

また、HTTPリクエストのキャッシュコントロールを [`HttpContext + Interceptor`](https://angular.dev/guide/http/interceptors#reading-the-token-in-an-interceptor) でリクエスト前後の処理を自分で実装するのではなく、提供されている `QueryClientConfig` から設定するアプローチにも目新しさを感じます。

ただAngularでTanStack Queryを使うだけではなく、素で `HttpClient` を使う場合と比較した際の優位性についても深掘りしてみます(気が向いたら続編を書きます)。
ありがとうございました!