- Angular
Angular resourceとlinkedSignalの使い方と使い分け
resource は「シグナルの値が変わるたびに非同期でデータを取り直し、結果・読み込み状態・エラーをシグナルで受け取れる」しくみで、linkedSignal は「別のシグナルに連動して初期化し直され、しかも自分で書き換えもできる」シグナルです。 サーバーからの読み込みは resource、読み込んだ一覧に対する「選択中の項目」のような画面側の状態は linkedSignal、と分担します。
この記事では、両方を Angular 22.2.1 のアプリで動かし、status の6つの値がどの操作で切り替わるかをコンソールのログで示します。resource・rxResource・httpResource の比較表、入力を連打したときに abortSignal で前の通信が中断される様子、以前の request 記法で出るコンパイルエラーと書き換え対応表、linkedSignal と computed・effect の違いも、すべて実行結果つきで載せています。
確認環境:Linux(x64)、Node.js 24.21.0、Angular 22.2.1(Angular CLI 22.2.1、TypeScript 6.0.3)、ブラウザは Playwright 1.56.0 の Chromium。API は 300 ミリ秒遅れて応答する検証用の Node.js サーバーです。
resource と linkedSignal の早見表
どれを使うか
| やりたいこと | 使うもの | import 元 |
|---|---|---|
| シグナルに連動して Promise でデータを読む | resource |
@angular/core |
| シグナルに連動して Observable(HttpClient など)でデータを読む | rxResource |
@angular/core/rxjs-interop |
| URL を決めるだけで HTTP の GET を読む | httpResource |
@angular/common/http |
| 値が何度も届く処理(進み具合など)を受ける | resource の stream |
@angular/core |
| 元のシグナルが変わったら初期値に戻し、手でも書き換えたい | linkedSignal |
@angular/core |
| 元のシグナルから計算するだけ(書き換えない) | computed |
@angular/core |
| シグナルの変化をきっかけに外部へ反映する(ログ、保存など) | effect |
@angular/core |
resource・rxResource・httpResource の比較
| 項目 | resource | rxResource | httpResource |
|---|---|---|---|
| 読み込みを書くオプション | loader(Promise を返す)または stream |
stream(Observable を返す) |
不要(URL か設定オブジェクトを返す関数を渡す) |
| 再読み込みのきっかけ | params が返す値の変化 |
params が返す値の変化 |
渡した関数の中で読んだシグナルの変化 |
| 通信の手段 | 自由(fetch など) |
自由(主に HttpClient) |
HttpClient(型定義のコメントに、インターセプターに対応とある) |
| 中断のしかた | abortSignal を自分で fetch に渡す |
stream の引数でも abortSignal を受け取れる |
指定不要 |
| 必要な設定 | なし | なし(HttpClient を使うなら provideHttpClient()) |
provideHttpClient() |
| 返ってくるもの | ResourceRef(value status error isLoading hasValue() reload() set() update()) |
同じ | 同じものに加えて headers statusCode progress |
| 向いている場面 | HTTP 以外も含む非同期処理全般 | 既存の Observable を活かしたいとき | 単純な GET |
status の6つの値
| status | いつなるか | isLoading() |
hasValue() |
|---|---|---|---|
idle |
params が undefined を返していて、読み込んでいない |
false | false |
loading |
params が変わって読み込み中 |
true | false |
reloading |
reload() で読み直し中(前の値は残る) |
true | true |
resolved |
読み込みが終わった | false | true |
error |
loader がエラーを投げた | false | false |
local |
set() / update() で手元の値に書き換えた |
false | true |
(isLoading() と hasValue() の列は、後述のログで確認した値です。)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular resourceとlinkedSignalの使い方と使い分け」
resource の基本:params と loader
resource に渡すオプションは、主に次の2つです。
params:シグナルを読んで「何を取りに行くか」を返す関数。返す値が変わるたびに loader が動きます。undefinedを返すと loader は動かず、statusはidleになります(公式ガイドにも同じ説明があります)。loader:paramsの値を受け取り、Promise を返す非同期関数。引数のオブジェクトにはparamsのほかに、中断を知らせるabortSignalと、直前の状態が入ったpreviousがあります。
分類を選ぶと、その分類の品目を取りに行く完成コードです。effect で状態をログに出しています。
// src/app/item.ts
export interface Item {
id: number;
name: string;
}
// src/app/status-demo.ts
import { Component, effect, resource, signal } from '@angular/core';
import { Item } from './item';
@Component({
selector: 'app-status-demo',
template: `
<button id="books" (click)="category.set('books')">本</button>
<button id="stationery" (click)="category.set('stationery')">文具</button>
<button id="oops" (click)="category.set('oops')">存在しない分類</button>
<button id="none" (click)="category.set(undefined)">未選択</button>
<button id="reload" (click)="items.reload()">再読み込み</button>
<button id="local" (click)="removeFirst()">先頭を消す</button>
@switch (items.status()) {
@case ('idle') { <p>分類を選んでください</p> }
@case ('loading') { <p>読み込み中…</p> }
@case ('error') { <p>読み込めませんでした: {{ items.error()?.message }}</p> }
@default {
<ul>
@for (item of items.value(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
}
}
`,
})
export class StatusDemo {
protected readonly category = signal<string | undefined>(undefined);
protected readonly items = resource({
params: () => this.category(),
loader: async ({ params, abortSignal }): Promise<Item[]> => {
const res = await fetch(`/api/items?category=${params}`, { signal: abortSignal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
});
constructor() {
effect(() => {
const r = this.items;
const names = r.hasValue() ? r.value().map((i) => i.name).join(',') : '(なし)';
console.log(`[status] ${r.status()} isLoading=${r.isLoading()} hasValue=${r.hasValue()} value=${names}`);
});
}
protected removeFirst(): void {
this.items.update((list) => list?.slice(1));
}
}
ポイントは3つあります。
fetchにabortSignalを渡す。これで、不要になった通信をブラウザが止められます。fetchは HTTP 500 でもエラーを投げないので、res.okを見て自分でthrowします。投げたエラーがitems.error()に入ります。- 値を読む前に
hasValue()で確かめる。型定義のコメントに、valueは「エラー状態のときはエラーを投げる」と書かれています。hasValue()が true のときだけvalue()を読む形にしておくと、エラー状態でも安心して扱えます。
status が切り替わる順番をログで確かめる
上のコンポーネントのボタンを順に押したときの、コンソール出力です(--- の行は操作の区切り)。
--- 画面を開く
[status] idle isLoading=false hasValue=false value=(なし)
--- 「本」を押す
[status] loading isLoading=true hasValue=false value=(なし)
[status] resolved isLoading=false hasValue=true value=入門書,図鑑,辞書
--- 「再読み込み」を押す
[status] reloading isLoading=true hasValue=true value=入門書,図鑑,辞書
[status] resolved isLoading=false hasValue=true value=入門書,図鑑,辞書
--- 「先頭を消す」を押す
[status] local isLoading=false hasValue=true value=図鑑,辞書
--- 「文具」を押す
[status] loading isLoading=true hasValue=false value=(なし)
[status] resolved isLoading=false hasValue=true value=ノート,ペン
--- 「存在しない分類」を押す
[status] loading isLoading=true hasValue=false value=(なし)
[status] error isLoading=false hasValue=false value=(なし)
画面: 読み込めませんでした: HTTP 500
--- 「未選択」を押す
[status] idle isLoading=false hasValue=false value=(なし)
この結果から分かることを整理します。
| 操作 | 切り替わり | 読み取れること |
|---|---|---|
最初(params が undefined) |
idle |
loader は呼ばれない |
params が変わる |
loading → resolved |
loading のあいだ、前の値は消える(hasValue=false) |
reload() |
reloading → resolved |
reloading のあいだ、前の値は残る(hasValue=true) |
update() / set() |
local |
通信はせず、手元の値だけが変わる |
loader が throw |
loading → error |
error() にエラーが入り、hasValue() は false |
params を undefined に戻す |
idle |
値も無くなる |
loading と reloading の違いは、画面の作り方に直結します。 分類を切り替えたとき(loading)は一覧がいったん空になるので「読み込み中…」を出し、再読み込み(reloading)は前の一覧を表示したまま上に小さな進行表示を重ねる、という出し分けができます。テンプレートの @switch (items.status()) はその形になっています。
切り替え中も前の値を見せておきたい場合は、defaultValue を指定して空配列などを既定値にするか、次に紹介する linkedSignal で「最後に読めた値」を持っておく方法があります。
local は「楽観的な更新」に使える
update() で local にした値は、次に params が変わるか reload() するまで保たれます。削除ボタンを押した直後に一覧から消しておき、サーバーへの削除が終わったら reload() で正しい内容に合わせる、という流れに使えます。なお、型定義のコメントには、resource は読み取りのためのもので、書き込みの通信には向かないと書かれています。新しい要求が来たときに進行中の通信を中断するため、書き込みが途中で止まるおそれがあるからです。登録や削除の通信そのものは、ふつうのメソッドで行います。
abortSignal で連打した入力の通信を中断する
検索欄のように入力のたびに params が変わる画面では、前の通信が不要になります。resource は、新しい要求が来ると前の loader に渡した abortSignal を中断状態にします。
// src/app/search-demo.ts
import { Component, resource, signal } from '@angular/core';
import { Item } from './item';
@Component({
selector: 'app-search-demo',
template: `
<input id="q" [value]="query()" (input)="query.set($any($event.target).value)" />
<p id="hits">{{ hits.status() }}: {{ names() }}</p>
`,
})
export class SearchDemo {
protected readonly query = signal('');
protected readonly hits = resource({
params: () => this.query() || undefined, // 空文字のときは undefined にして読み込まない
loader: async ({ params, abortSignal }): Promise<Item[]> => {
console.log(`[loader] 開始 q=${params}`);
abortSignal.addEventListener('abort', () => console.log(`[loader] 中断 q=${params}`));
const res = await fetch(`/api/items?q=${encodeURIComponent(params)}`, { signal: abortSignal });
const json = await res.json();
console.log(`[loader] 完了 q=${params}`);
return json;
},
});
protected names(): string {
return this.hits.hasValue() ? this.hits.value().map((i) => i.name).join(',') : '';
}
}
検索欄に「ノ」「ノー」「ノート」を 50 ミリ秒おきに入力したときのログです。
[loader] 開始 q=ノ
[loader] 中断 q=ノ
[loader] 開始 q=ノー
[loader] 中断 q=ノー
[loader] 開始 q=ノート
[loader] 完了 q=ノート
画面: resolved: ノート
サーバー側のログでも、前の2件は応答を返す前に接続が切られていました。
[server] 中断された: /api/items?q=%E3%83%8E
[server] 中断された: /api/items?q=%E3%83%8E%E3%83%BC
[server] 応答した: /api/items?q=%E3%83%8E%E3%83%BC%E3%83%88
最後の入力の結果だけが画面に反映され、途中の通信はブラウザ側で打ち切られています。 「あとから届いた前の応答で画面が上書きされる」という順番の入れ違いを、自分でフラグを持たずに避けられます。
通信そのものを止めるのは fetch の役目なので、fetch には { signal: abortSignal } を渡しておきます。入力のたびに通信が始まること自体を減らしたいときは、入力が止まってから params 用のシグナルを更新するようにします。
rxResource と httpResource:同じ読み込みを3通りで書く
同じ API を、resource・rxResource・httpResource の3通りで書いて並べます。HttpClient を使う2つのために、provideHttpClient() を設定します。
// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideHttpClient(withFetch())],
};
// src/app/three-demo.ts
import { Component, effect, inject, resource, signal } from '@angular/core';
import { HttpClient, httpResource } from '@angular/common/http';
import { rxResource } from '@angular/core/rxjs-interop';
import { Item } from './item';
@Component({
selector: 'app-three-demo',
template: `<button id="three" (click)="category.set('stationery')">文具に切り替え</button>`,
})
export class ThreeDemo {
private readonly http = inject(HttpClient);
protected readonly category = signal('books');
// 1. resource: Promise を返す loader
readonly a = resource({
params: () => this.category(),
loader: ({ params, abortSignal }) =>
fetch(`/api/items?category=${params}&by=resource`, { signal: abortSignal }).then((r) => r.json() as Promise<Item[]>),
});
// 2. rxResource: Observable を返す stream
readonly b = rxResource({
params: () => this.category(),
stream: ({ params }) => this.http.get<Item[]>('/api/items', { params: { category: params, by: 'rx' } }),
});
// 3. httpResource: URL を返す関数を渡すだけ
readonly c = httpResource<Item[]>(() => `/api/items?category=${this.category()}&by=http`);
constructor() {
const names = (v: Item[] | undefined) => v?.map((i) => i.name).join(',') ?? '-';
effect(() => console.log(`[resource] ${this.a.status()} ${names(this.a.value())}`));
effect(() => console.log(`[rxResource] ${this.b.status()} ${names(this.b.value())}`));
effect(() => console.log(`[httpResource] ${this.c.status()} ${names(this.c.value())}`));
}
}
(どの書き方からの通信かを見分けられるよう、URL に by を付けています。)
実行結果です。3つとも同じ順番で状態が進みます。
--- 画面を開く
[resource] loading -
[rxResource] loading -
[httpResource] loading -
[resource] resolved 入門書,図鑑,辞書
[rxResource] resolved 入門書,図鑑,辞書
[httpResource] resolved 入門書,図鑑,辞書
--- 「文具に切り替え」を押す
[resource] loading -
[rxResource] loading -
[httpResource] loading -
[resource] resolved ノート,ペン
[rxResource] resolved ノート,ペン
[httpResource] resolved ノート,ペン
3つの選び方
| 状況 | 選ぶもの | 理由 |
|---|---|---|
| GET で JSON を読むだけ | httpResource |
URL を返す関数だけで済み、コードがいちばん短い |
| インターセプター(認証ヘッダーの付与など)を通したい | httpResource か rxResource + HttpClient |
どちらも HttpClient を通る |
| 既存のサービスが Observable を返す | rxResource |
stream にそのまま返せる |
map や switchMap などで加工してから受けたい |
rxResource |
RxJS の演算子が使える |
| HTTP 以外(IndexedDB、Web Worker、SDK の Promise) | resource |
loader は Promise を返せば何でもよい |
| 値が何度も届く | resource の stream |
次の節 |
rxResource の stream に渡す Observable は、値かエラーを少なくとも1回出してから完了する必要があります。型定義のコメントに、何も出さずに完了すると NG0991 のエラーになると書かれています(たとえば catchError(() => EMPTY) で握りつぶした場合)。エラー時に空の一覧を見せたいなら、catchError(() => of([])) のように値を返します。
値が何度も届く処理は stream で受ける
loader の代わりに stream を渡すと、1回の読み込みのあいだに値を何度も更新できます。stream は「{ value: … } か { error: … } を持つシグナル」を返し、新しい値が届くたびにそのシグナルを set します。
// src/app/stream-demo.ts
import { Component, effect, resource, signal, ResourceStreamItem } from '@angular/core';
@Component({
selector: 'app-stream-demo',
template: `<p id="progress">{{ progress.status() }}: {{ progress.value() }}</p>`,
})
export class StreamDemo {
// stream: 値が何度も届く処理。signal を返し、届くたびに set する
protected readonly progress = resource({
defaultValue: 0,
stream: async ({ abortSignal }) => {
const state = signal<ResourceStreamItem<number>>({ value: 0 });
let percent = 0;
const timer = setInterval(() => {
percent += 50;
state.set({ value: percent });
if (percent >= 100) clearInterval(timer);
}, 100);
abortSignal.addEventListener('abort', () => clearInterval(timer));
return state;
},
});
constructor() {
effect(() => console.log(`[stream] ${this.progress.status()} value=${this.progress.value()}`));
}
}
実行結果です。
[stream] loading value=0
[stream] resolved value=0
[stream] resolved value=50
[stream] resolved value=100
defaultValue: 0 を指定しているので、loading のあいだも value() は 0 を返しています。最初のシグナルを返した時点で resolved になり、そのあとは resolved のまま値だけが 50、100 と進みます。WebSocket やサーバー送信イベントのように届き続けるデータでも、同じ形で受けられます。abortSignal の abort イベントでタイマーや接続を片付けておくと、コンポーネントが破棄されたときに処理が残りません。
以前の request 記法からの書き換え対応表
v19 の頃に書かれた解説記事では、オプション名が request になっています。Angular 22.2.1 で同じ書き方をすると、コンパイルエラーになります。
// 以前の書き方(v19 の頃)
export const a = resource({
request: () => id(),
loader: async ({ request }) => request * 2,
});
export const b = rxResource({
request: () => id(),
loader: ({ request }) => of(request * 2),
});
ng build の出力(要点)です。
✘ [ERROR] TS2769: No overload matches this call.
Object literal may only specify known properties, and 'request' does not exist in type 'PromiseResourceOptions<number, unknown> & { debugName?: string | undefined; }'.
9 │ request: () => id(),
╵ ~~~~~~~
✘ [ERROR] TS2339: Property 'request' does not exist on type 'ResourceLoaderParams<unknown>'.
10 │ loader: async ({ request }) => request * 2,
╵ ~~~~~~~
✘ [ERROR] TS2769: No overload matches this call.
Object literal may only specify known properties, and 'request' does not exist in type 'RxResourceOptions<unknown, unknown>'.
14 │ request: () => id(),
╵ ~~~~~~~
書き換えの対応は次のとおりです。
| 場所 | 以前の書き方 | 22.2.1 での書き方 |
|---|---|---|
| resource のオプション | request: () => id() |
params: () => id() |
| resource の loader の引数 | loader: ({ request }) => … |
loader: ({ params }) => … |
| rxResource のオプション | request: () => id() |
params: () => id() |
| rxResource の読み込み関数 | loader: ({ request }) => observable |
stream: ({ params }) => observable |
書き換え後のコードです。
export const a = resource({
params: () => id(),
loader: async ({ params }) => params * 2,
});
export const b = rxResource({
params: () => id(),
stream: ({ params }) => of(params * 2),
});
status は、22.2.1 の型定義では 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local' という文字列の型です。テンプレートの @switch や @if で、そのまま文字列と比べられます。また、型定義では resource 関連の API に「publicApi 22.0」と付いており、v22 で安定版として扱われています。
linkedSignal の使い方:選択中の項目をリセットする/維持する
ここからは linkedSignal です。「一覧」と「その中で選択中の項目」のように、ある状態が別の状態に従属している場面で使います。
- 一覧が変わったら、選択は先頭に戻したい(リセット型)
- 一覧が変わっても、同じ項目が残っていれば選択を続けたい(維持型)
どちらも、選択そのものはユーザーの操作で書き換えられる必要があります。linkedSignal には書き方が2つあります。
| 書き方 | 形 | 使いどころ |
|---|---|---|
| 短い形 | linkedSignal(() => 計算) |
元が変わったら計算し直すだけでよいとき(リセット型) |
source と computation の形 |
linkedSignal({ source, computation: (新しい元の値, previous) => … }) |
前の値を見て決めたいとき(維持型)。previous.source は前の元の値、previous.value は前の自分の値 |
比較のために、computed と「signal + effect」で同じことをしようとした場合も並べた完成コードです。
// src/app/linked-demo.ts
import { Component, computed, effect, linkedSignal, signal } from '@angular/core';
import { Item } from './item';
const BOOKS: Item[] = [
{ id: 1, name: '入門書' },
{ id: 2, name: '図鑑' },
{ id: 3, name: '辞書' },
];
// 一覧を取り直した結果: 「入門書」が消え、「年鑑」が先頭に増えた
const BOOKS_UPDATED: Item[] = [
{ id: 4, name: '年鑑' },
{ id: 2, name: '図鑑' },
{ id: 3, name: '辞書' },
];
@Component({
selector: 'app-linked-demo',
template: `
<button id="pick2" (click)="pick(2)">図鑑を選ぶ</button>
<button id="pick1" (click)="pick(1)">入門書を選ぶ</button>
<button id="refresh" (click)="refresh()">一覧を更新</button>
<button id="back" (click)="items.set(books)">一覧を戻す</button>
`,
})
export class LinkedDemo {
protected readonly books = BOOKS;
protected readonly items = signal<Item[]>(BOOKS);
// A. 一覧が変わるたびに先頭へリセットする
readonly resetSelected = linkedSignal(() => this.items()[0]);
// B. 同じ id が新しい一覧にあれば維持し、無ければ先頭にする
readonly keepSelected = linkedSignal<Item[], Item>({
source: this.items,
computation: (list, previous) => list.find((i) => i.id === previous?.value.id) ?? list[0],
});
// C. computed: 読み取り専用(set できない)
readonly computedFirst = computed(() => this.items()[0]);
// D. signal + effect: effect が走るまで値が追いつかない
readonly effectSelected = signal<Item>(BOOKS[0]);
constructor() {
effect(() => this.effectSelected.set(this.items()[0]));
}
protected pick(id: number): void {
const item = this.items().find((i) => i.id === id)!;
this.resetSelected.set(item);
this.keepSelected.set(item);
this.effectSelected.set(item);
this.log(`${item.name}を選んだ直後`);
}
protected refresh(): void {
this.items.set(BOOKS_UPDATED);
this.log('一覧を更新した直後');
setTimeout(() => this.log('effect が走った後'));
}
private log(label: string): void {
console.log(
`[linked] ${label}: リセット型=${this.resetSelected().name} 維持型=${this.keepSelected().name} ` +
`computed=${this.computedFirst().name} effect型=${this.effectSelected().name}`,
);
}
}
実行結果1:選んだ項目が新しい一覧にも残っている場合
「図鑑」を選んでから一覧を更新します。更新後の一覧にも図鑑(id 2)はあります。
[linked] 図鑑を選んだ直後: リセット型=図鑑 維持型=図鑑 computed=入門書 effect型=図鑑
[linked] 一覧を更新した直後: リセット型=年鑑 維持型=図鑑 computed=年鑑 effect型=図鑑
[linked] effect が走った後: リセット型=年鑑 維持型=図鑑 computed=年鑑 effect型=年鑑
実行結果2:選んだ項目が新しい一覧から消えた場合
「入門書」を選んでから一覧を更新します。更新後の一覧に入門書(id 1)はありません。
[linked] 入門書を選んだ直後: リセット型=入門書 維持型=入門書 computed=入門書 effect型=入門書
[linked] 一覧を更新した直後: リセット型=年鑑 維持型=年鑑 computed=年鑑 effect型=入門書
[linked] effect が走った後: リセット型=年鑑 維持型=年鑑 computed=年鑑 effect型=年鑑
computed・effect との違い
2つの結果を表にまとめます。
| 方式 | 手で書き換えられるか | 一覧を更新した直後の値 | 選択の維持 |
|---|---|---|---|
| linkedSignal(短い形) | できる | すぐ先頭(年鑑)に戻る | しない |
linkedSignal(source + computation) |
できる | 残っていれば維持(図鑑)、消えていれば先頭(年鑑) | する |
| computed | できない(常に先頭を返す。図鑑を選んでも入門書のまま) | すぐ先頭 | ― |
| signal + effect | できる | 更新した直後は前の値のまま。effect が走ってから追いつく | 自分で書けば可能 |
読み取れることは次の3点です。
- computed は書き換えられません。 実行結果1の1行目で、computed だけが「入門書」のままです。
setを呼ぶコードを書くと、TS2339: Property 'set' does not exist on type 'Signal<number>'.というコンパイルエラーになります。 - signal + effect は、値が一瞬ずれます。 実行結果2の2行目では、一覧にもう存在しない「入門書」が選択中のままになっています。effect はあとから非同期に走るためで、このあいだに選択中の項目を使う処理が動くと、一覧と食い違った値を扱うことになります。
- linkedSignal は、読んだ時点で必ず最新の一覧に合った値を返します。 上のログでも、一覧を更新した直後に読んだ値がすでに新しい一覧に合っています。
「元の値に合わせて別のシグナルを set する effect」を書きたくなったら、linkedSignal に置き換えられないかを考えるのが近道です。
resource と linkedSignal を組み合わせる
読み込みは resource、読み込んだ一覧に対する選択は linkedSignal、という組み合わせの完成コードです。
// src/app/combo-demo.ts
import { Component, effect, linkedSignal, resource, signal } from '@angular/core';
import { Item } from './item';
@Component({
selector: 'app-combo-demo',
template: `
<button id="c-books" (click)="category.set('books')">本</button>
<button id="c-stationery" (click)="category.set('stationery')">文具</button>
<button id="c-reload" (click)="items.reload()">再読み込み</button>
<ul>
@for (item of list(); track item.id) {
<li>
<button [id]="'item-' + item.id" (click)="selected.set(item)">{{ item.name }}</button>
</li>
}
</ul>
<p id="c-selected">選択中: {{ selected()?.name ?? '(なし)' }}</p>
`,
})
export class ComboDemo {
protected readonly category = signal('books');
// 非同期の読み込みは resource
protected readonly items = resource({
params: () => this.category(),
loader: ({ params, abortSignal }) =>
fetch(`/api/items?category=${params}`, { signal: abortSignal }).then((r) => r.json() as Promise<Item[]>),
});
// 読み込み中やエラーのときは空の一覧として扱う
protected readonly list = () => (this.items.hasValue() ? this.items.value() : []);
// 一覧に連動する「選択中」は linkedSignal(同じ id があれば維持)
protected readonly selected = linkedSignal<Item[], Item | undefined>({
source: this.list,
computation: (list, previous) => list.find((i) => i.id === previous?.value?.id) ?? list[0],
});
constructor() {
effect(() => console.log(`[combo] ${this.items.status()} 選択中=${this.selected()?.name ?? '(なし)'}`));
}
}
実行結果です。
--- 画面を開く
[combo] loading 選択中=(なし)
[combo] resolved 選択中=入門書
--- 「図鑑」を選ぶ
[combo] resolved 選択中=図鑑
--- 「再読み込み」を押す
[combo] reloading 選択中=図鑑
[combo] resolved 選択中=図鑑
--- 「文具」に切り替え
[combo] loading 選択中=(なし)
[combo] resolved 選択中=ノート
再読み込みでは選択(図鑑)が保たれ、分類を切り替えると新しい一覧の先頭(ノート)に変わります。 「reloading のあいだは前の値が残る」「loading のあいだは値が無い」という resource の動きと、「同じ id があれば維持する」という linkedSignal の計算が、そのままかみ合っています。
使い分けの判断表
| 判断のポイント | resource | linkedSignal |
|---|---|---|
| 値の出どころ | 非同期(サーバー、ストレージなど) | 同期(ほかのシグナルからの計算) |
| 値が変わるきっかけ | params の変化、reload()、set() / update() |
元のシグナルの変化、set() / update() |
| 持っている状態 | value のほかに status error isLoading |
値だけ |
| 中断 | abortSignal で前の処理を中断 |
―(同期なので不要) |
| 代表的な用途 | 一覧・詳細の読み込み、検索結果 | 選択中の項目、数量の初期値、編集中の下書き |
| 近いもの | rxResource httpResource |
computed(書き換えないとき) |
| こういうとき | 選ぶもの |
|---|---|
| ID が変わったら詳細を取り直したい | resource(または httpResource) |
| 商品を切り替えたら数量を1に戻したいが、数量は手でも変えたい | linkedSignal |
| 合計金額のように、計算するだけ | computed |
| 入力値を localStorage に保存したい | effect |
| サーバーの値を初期値にしたフォームの下書き | resource + linkedSignal(source に resource の値) |
つまずきやすい点と直し方
| 起きること | 理由 | 直し方 |
|---|---|---|
'request' does not exist in type … のコンパイルエラー |
以前のオプション名 | params に書き換える(rxResource は loader も stream に) |
HTTP 500 なのに resolved になる |
fetch はステータスコードではエラーにならない |
res.ok を見て throw する |
| 分類を切り替えると一覧が一瞬消える | loading のあいだは値が無い |
defaultValue を指定するか、loading 用の表示を用意する |
| 最初から読み込みたくないのに通信が走る | params が値を返している |
条件がそろうまで undefined を返す |
| 連打すると通信が何本も走る | abortSignal を渡していない |
fetch(url, { signal: abortSignal }) |
computed に set できない |
computed は読み取り専用 | linkedSignal にする |
| 一覧と選択が一瞬食い違う | effect で別のシグナルを set している |
linkedSignal にする |
よくある質問
Q. resource は HttpClient を使いますか?
A. 使いません。loader の中身は自由で、この記事の例では fetch を使っています。HttpClient のインターセプターを通したいときは、httpResource か、rxResource の stream で HttpClient を呼びます。
Q. 登録や更新(POST・PUT)にも resource を使えますか?
A. 読み取り用です。型定義のコメントに、新しい要求や破棄のタイミングで進行中の処理を中断するため、書き込みには向かないと書かれています。書き込みはふつうのメソッドで行い、終わったら reload() で一覧を読み直します。
Q. linkedSignal と computed は、どちらを先に検討すればよいですか? A. 書き換える必要が無ければ computed で十分です。「基本は元の値から決まるが、ユーザーの操作でも変えたい」ときに linkedSignal を選びます。
Q. reload() の戻り値は何ですか?
A. 型定義では boolean で、読み直しを始めたら true、不要または対応していない場合は false と説明されています。
まとめ
- resource は
paramsとloaderで書く。paramsが変わるたびに読み直し、undefinedを返すあいだはidleのまま - status は6つ。
loadingでは前の値が消え、reloadingでは残る。set()/update()でlocal、エラーでerror abortSignalをfetchに渡すと、連打した入力の途中の通信が中断され、最後の結果だけが残る- rxResource は
streamに Observable、httpResource は URL を返す関数。単純な GET は httpResource がいちばん短い - 以前の
requestはparamsに、rxResource のloaderはstreamに書き換える - linkedSignal は「連動して初期化+手で書き換え」。
sourceとcomputationの形なら、前の選択を維持できる - effect で別のシグナルを
setする形は値が一瞬ずれる。linkedSignal なら読んだ時点で常に一致する
参考・出典
- Angular 公式ガイド「Async reactivity with resources」(https://angular.dev/guide/signals/resource):
params・loader・stream、paramsがundefinedのときの動き、status の一覧 - Angular 公式ガイド「Dependent state with linkedSignal」(https://angular.dev/guide/signals/linked-signal):短い形と
source/computationの形、previous @angular/core22.2.1 の型定義(ResourceStatus、ResourceLoaderParams、RxResourceOptions、resourceのコメント)- Angular v19 resource() の解説(https://zenn.dev/lacolaco/articles/angular-v19-resource):
requestを使った以前の書き方の例
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular resourceとlinkedSignalの使い方と使い分け」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。