lacolaco's marginalia

Angular: Resource APIファミリーの体系図

この記事では、Angular v22で安定版となったResource APIファミリーについて、その関係を概観する。

Resource APIファミリー

v22時点で Resource API に含まれる主要な公開APIは次のものである。

  • Resource<T> インターフェース
  • WritableResource<T> インターフェース
  • ResourceSnapshot<T> インターフェース
  • resourceFromSnapshots 関数
  • resource 関数
  • httpResource 関数

image

これらのAPIをひとつずつ解説しながら、APIファミリー内での位置づけを確認していこう。

Resource<T> インターフェース

このインターフェースはAPIファミリーの中心にある。Resource APIファミリーは大きく分けて、Resourceオブジェクトを表すAPI群と、Resourceオブジェクトを作成するためのファクトリーAPI群とに分けられる。Resource<T> インターフェースはResourceオブジェクトすべてに共通する基底型であり、ファクトリーAPIの戻り値になる型でもある。

export interface Resource<T> {
  readonly value: Signal<T>;
  readonly status: Signal<ResourceStatus>;
  readonly error: Signal<Error | undefined>;
  readonly isLoading: Signal<boolean>;
  readonly snapshot: Signal<ResourceSnapshot<T>>;

  hasValue(this: T extends undefined ? this : never): this is Resource<Exclude<T, undefined>>;
  hasValue(): boolean;
}

見てわかるように、Resource<T>は読み取り専用であり、そのResourceが持っている値、状態をSignal型で返すのが責務である。このインターフェースに準拠していれば、そのオブジェクトはResourceオブジェクトであると言える。もし、Resourceオブジェクトを引数にとって何かを行うコードを書くのであれば、その引数はResource<T>型を要求する。

WritableResource<T> インターフェース

WritableResource<T> インターフェースは、Resource<T>を拡張したインターフェースであり、その違いは書き込みをサポートしていることだ。valueフィールドはWritableSignal<T>型であり、setメソッドとupdateメソッドも提供している。asReadonlyメソッドで読み取り専用のResource<T>に変換することもできる。また、reloadメソッドは、最後に実行されたリソース解決を再実行するもので、これも内部的にvalueを更新する。つまるところ、WritableResource<T> は利用者側で値の更新をトリガーできるものだ。

export interface WritableResource<T> extends Resource<T> {
  readonly value: WritableSignal<T>;

  set(value: T): void;
  update(updater: (value: T) => T): void;
  asReadonly(): Resource<T>;
  reload(): boolean;
}

image

ResourceSnapshot<T> インターフェース

ResourceSnapshot<T> インターフェースは、Resourceオブジェクトが内包する状態をある瞬間で切り取った不変のオブジェクトを表現する。具体的には、Resourceによる値の解決状態を示すstatusフィールドと、それぞれのステータスに対応した追加のフィールドだ。Resourceは非同期的に状態が変化するオブジェクトだが、その状態は常に、これらのスナップショット型のいずれかに該当するいわゆるDiscriminated Union型となっている。

export type ResourceSnapshot<T> =
  | {readonly status: 'idle'; readonly value: T}
  | {readonly status: 'loading' | 'reloading'; readonly value: T}
  | {readonly status: 'resolved' | 'local'; readonly value: T}
  | {readonly status: 'error'; readonly error: Error};

Resource<T>snapshotフィールドは、その呼び出し時点での状態をResourceSnapshot<T>型のオブジェクトとして返す関係にある。

image

resourceFromSnapshots 関数

Resource<T>が取りうる状態がResourceSnapshot<T>として常に切り出し可能ということは、ResourceSnapshot<T>の連続的な変化こそがResource<T>そのものだと言える。つまり、Resource<T>とは、Signal<ResourceSnapshot<T>>なのである。

それをそのまま表しているのが resourceFromSnapshots関数だ。引数にはResourceSnapshot<T>を返す関数を取る。引数がSignal<ResourceSnapshot<T>>であれば、内部的に購読されて自動的にResource<T>の状態に反映される。ソースが状態の情報源だから、返されるのは読み取り専用のResourceオブジェクトだ。

function resourceFromSnapshots<T>(
  source: () => ResourceSnapshot<T>,
): Resource<T>;
  const source = signal<ResourceSnapshot<string>>({status: 'idle', value: ''});
  const res = resourceFromSnapshots(source);
  expect(res.status()).toEqual('idle');
  expect(res.value()).toEqual('');
  expect(res.isLoading()).toBeFalse();
  expect(res.hasValue()).toBeTrue();

  // スナップショットの更新
  source.set({status: 'loading', value: 'alpha'});
  expect(res.status()).toEqual('loading');
  expect(res.value()).toEqual('alpha');
  expect(res.isLoading()).toBeTrue();
  expect(res.hasValue()).toBeTrue();

ソースのSignalをどう作るかも完全に自由であり、signal関数でもinput関数でも、linkedSignal関数でもcomputed関数でも構わない。最終的にSignal<ResourceSnapshot<T>> でさえあればいい。用途にあわせてResourceオブジェクトを作るのであれば、この方法はかなりお手軽だ。もちろん、後述するビルトインのファクトリーAPIで済むのであればそれでいい。

image

resource 関数

resource 関数はビルトインのResourceファクトリーAPIだ。resourceFromSnapshots関数と違い、宣言的なオプションを元に読み書き可能なResourceオブジェクトを返却する。ResourceRef<T>WritableResource<T>とほぼ同じものだ。

function resource<T, R>(
  options: ResourceOptions<T, R> & { defaultValue: NoInfer<T> },
): ResourceRef<T>;

APIの使い方自体はドキュメントを読めばわかるので割愛するが、大まかには2つの方法でResourceオブジェクトを構成できる。ひとつはPromiseを返すLoaderによる構成、もうひとつはStreamを返すLoaderによる構成だ。ここでいうStreamとは連続する値のことで、具体的にはSignal<ResourceStreamItem<T>>を意味する。ResourceStreamItem<T> は具体的に {value: T} | {error: Error} を指す。

const userId: Signal<string> = getUserId();

// Promise-based Loader
const userResource = resource({
  params: () => ({id: userId()}),
  loader: ({params}): Promise<User> => fetchUser(params),
});

// Signal-based Streaming Loader
const chunkedMessageResource = resource({
  params: () => ({id: messageId()}),
  stream: ({params}): Signal<ResourceStreamItem<string>> => {
    const message = signal<string>('');
    chunkedMessage(params).subscribe({
      next: (chunk) => {
        message.update(item => ({ value: item.value + chunk }));
      },
      error: (err) => message.set({ error: err }),
    });
  },
});

値の解決が一回で完了するPromiseモデルではなく、複数回の値書き込みが必要になる用途であれば、StreamベースのLoaderで構成できる。しかし、このユースケースは基本的に先述のresourceFromSnapshotsのほうが汎用的で使いやすく、個人的にはいずれ無くなってもおかしくないと思っている。特に理由がなければ resource関数を使うのはPromiseベースのLoaderを持っているときに限るのがいいだろう。

image

httpResource 関数

最後のhttpResource 関数は、resource関数と同じくビルトインのResourceファクトリーAPIだが、用途が限定されている。その名のとおり、HTTPリクエストによって値を解決するResourceを構成するためのファクトリーだ。urlを解決する関数を受け取り、内部的にAngularのHttpClientを使って取得されたレスポンスをResourceオブジェクトとして取得できる。用途が明確なので、他のResourceファクトリーとの使い分けには困らないだろう。

function httpResource<T>(
  url: (ctx: ResourceParamsContext) => string | undefined, 
  options: HttpResourceOptions<T, unknown> & { defaultValue: NoInfer<T>; }
): HttpResourceRef<T>;

const userId = signal<string>('id');
const user = httpResource(() => `/api/user/${userId()}`);

HttpResourceRef<T> インターフェースはWritableResource<T>にHTTP特有の状態を加えたものだ。valueWritableResource<T>に管理されるため読み書き可能だが、レスポンスのヘッダやステータスコードは読み取り専用になる。また、asReadonlyするとただのResource<T>になってしまいHTTP特有の情報は失われることに注意が必要だ。

export interface HttpResourceRef<T> extends WritableResource<T>, ResourceRef<T> {
  readonly headers: Signal<HttpHeaders | undefined>;
  readonly statusCode: Signal<number | undefined>;
  readonly progress: Signal<HttpProgressEvent | undefined>;
}

image

まとめ

image

ここまで登場した主要APIの関係像をまとめた。Resource<T>を中心として、その派生インターフェースと対応するファクトリー関数がビルトインで提供されている。同時に、インターフェースに対する実装は可換である。ビルトインのファクトリーAPIは特別なものではなく、個別のユースケースに特化した実装を作れるように開かれている。この関係を押さえておくとResource APIをうまく使いこなせるようになるだろう。