Marginalia

Angular v19: resource() の解説

この記事ではAngular v19で新たに追加されるSignal関連の実験的API resource() について解説する。なお、書いている時点で最新の v19.0.0-next.11 をベースにしているため、正式リリースまでに変更される可能性はある。また、そもそも実験的APIなのでリリース後にも変更されている可能性はあることに注意してほしい。

Angular v19についての他の記事

resource() とは何か?

resource() は、非同期的に読み込まれるデータをシグナルとして扱えるようにするAPIである。

具体的なコードを見ればわかりやすい。次のコードは、resource()を使ってHTTP通信を行い、サーバーAPIから製品情報を取得している。リクエストのパラメータには親コンポーネントから受け取ったproductIdが使われている。productIdの値が変更されたらデータの再取得を行う。非同期データの値と取得状態がカプセル化されているのがResourceRef型のproductResourceフィールドである。

@Component({
  selector: 'app-product-viewer',
  template: `
  @if (productResource.value(); as product) {
    <p> Title: {{ product.title }} </p>
  } @else if (productResource.error(); ) {
    <p> load failed </p>
  } @else if(productResource.isLoading()) {
    <p> loading... </p>
  }
  `,
})
export class ProductViewer {
  productId = input.required<number>();

  productResource: ResourceRef<Product> = resource({
    request: () => this.productId(), // load on productId change
    loader: async ({ request: productId, abortSignal }) => {
      const resp = await fetch(`https://dummyjson.com/products/${productId}`, {
        signal: abortSignal,
      });
      return resp.json() as Promise<Product>;
    },
  });
}

resource()が引数に取るオブジェクトの中身を詳しく見てみよう。

requestプロパティは、非同期データの取得をトリガーするリクエストパラメータを返す関数である。この関数はcomputed()と同等に、他のシグナルのgetterを呼び出すことで派生値を生み出し、依存したシグナルの更新により再計算される。つまり、上述の例では、this.productId()の値が更新されるたびにリクエストも更新され、データ取得がトリガーされる。

loaderプロパティはデータの取得手続きを記述する関数である。requestプロパティの関数が返した値を引数から取り出して、実際のデータ読み込みに利用できる。最終的にこの関数が返したオブジェクトはResourceRef.value()シグナルに格納されることになる。返り値がPromiseであれば解決後の中身だけがシグナルに格納されるため、Signal<Promise<T>> にはならない。

loader関数の引数にはabortSignalも含まれており、Angular側のスケジューリングやライフサイクル管理によってコンポーネントが破棄されるときには進行中のリクエストも中断できるように、データ取得ロジックの中で利用できる。

これがresource()の基本的なインターフェースと使い方である。ここで例に挙げたのはFetch APIを使ったHTTP通信によるデータ取得だが、パラメータを引数にとってTまたはPromise<T>で値を返すインターフェースに合致するならば、どのようなデータソースでもいいし、どのような取得方法でもよい。Local StorageやIndexedDBへのアクセスをラップしてもよいし、Web Workerを使って別スレッドで計算した結果を取得するというのもありえるだろう。

従来はこのようなユースケースはsignal()effect()によって解決されていたが、副作用として何でもできてしまうeffect()を使わずに済み、なおかつ意図が明確なresource()ひとつで完結するのは嬉しい改善だ。上述の例をresource()なしでやろうとすると次のようになるが、やることに対してコードが多く複雑すぎる。

// resource() がない場合
export class ProductViewer {
  productId = input.required<number>();
  productData = signal<Product | null>(null);
  isProductLoading = signal<boolean>(false);

	constructor() {
	  effect(async (onCleanup) => {
		  const productId = this.productId();
		  this.isProductLoading.set(true);
		  const abortCtrl = new AbortController();
      onCleanup(() => abortCtrl.abort())
		  
		  const resp = await fetch(`https://dummyjson.com/products/${productId}`, {
        signal: abortCtrl.signal,
      });
      const data = await resp.json() as Promise<Product>;
      this.productData.set(data);
      this.isProductLoading.set(false);
	  });
	}
}

HttpClientrxResource

ところで、Angularで非同期データの取得といえばHttpClient APIが代表的な機能だが、ここまでの例に登場していない。上述のサンプルコードでは意図的にWeb標準のFetch APIを使っている。

なぜかというと、resource()loader関数はPromiseからシグナルへの変換を行うが、Obervable型の値からシグナルへの変換をしないからだ。HttpClientのメソッドが返す値はObservableなので、リターンする前に自前で変換する必要がある。RxJSが提供しているfirstValueFrom関数を使えば変換はできるが、resource()というAngularのコアAPI(候補)の中で、Observableは第一級サポートされないインターフェースである。

export class ProductViewer {
  productId = input.required<number>();
  http = inject(HttpClient);

  productResource: ResourceRef<Product> = resource({
    request: () => this.productId(), // load on productId change
    loader: ({ request: productId, abortSignal }) => {
      const destroy$ = fromEvent(abortSignal, "abort");
      return firstValueFrom(
        this.http.get<Product>(`https://dummyjson.com/products/${productId}`)
          .pipe(takeUntil(destroy$))
      );
    },
  });
}

とはいえ実際には多くのアプリケーションでHttpClientが使われており、resource()との併用が望まれるのも当然わかりきっているので、RxJSとの相互運用性のためのサブパッケージ @angular/core/rxjs-interop からrxResource()というAPIも提供される。これはresource()とほぼ同じインターフェースを持っているが、loader関数がObservable型にも対応している。次のサンプルコードのように、HttpClientのメソッドの戻り値を返すだけで、コンポーネントの破棄によるリクエストの中断も含めてすべてやってくれる。

export class ProductViewer {
  productId = input.required<number>();
  http = inject(HttpClient);

  productResource: ResourceRef<Product> = rxResource({
    request: () => this.productId(), // load on productId change
    loader: ({ request: productId }) => {
      return this.http.get<Product>(`https://dummyjson.com/products/${productId}`);
    },
  });
}

AngularのフレームワークコアからだんだんとObservableの第一級サポートが消えていっているが、一方でHTTPクライアントやフォームAPIなどにはまだまだObservableベースのAPIが残っている。それらがまだ必要な間は無理にresource()のようなコアAPIにこだわらなくても、rxResource()などの相互運用性パッケージを利用して何も問題ないだろう。待っていれば公式にObservable非依存のHTTPクライアントも来るだろうから、そのときに乗り換えればいい。

まとめ

  • resource()は、Angular v19で導入される新しいSignal関連の実験的APIである。
  • 非同期データの取得と管理を簡潔に行うことができ、従来のsignal()effect()の組み合わせよりも意図が明確になる。
  • requestloader関数を指定することで、データの取得条件とロジックを定義できる。
  • Promiseベースのインターフェースで、Fetch API、IndexedDB、Web Workerなど、様々なデータソースに対応可能。
  • HttpClientとの併用にはrxResource()が提供され、Observableとの相互運用性を確保している。
  • 将来的にObservable非依存のHTTPクライアントが登場する可能性があるが、それまでは相互運用性パッケージを利用することで問題なく開発を進められる。

今回のサンプルコードもStackblitzに置いているので好きに使ってほしい。