世間ではすっかりSignalが定着し、状態管理やFormなどの領域にもSignalベースの手法が模索されるようになりましたが、自分はまだ基本的なSignalの使い方しか知らなかったのでちょっと練習してみました。
以前Angular AI Tutorで書いた料理レシピ取得アプリケーションをベースに、今回はHttpResourceを導入しました。
HttpResourceの導入
HttpResourceは、Angular v19.2から実験的に導入されたHTTP Clientで、API経由の非同期的なデータ取得をラップし、Signalとして扱えるようになります。
今までのObservableの世界のHttpClientとは異なるやり方ができそうで、興味を持って導入してみました。
RecipeService
以下の2つのAPIを叩いて結果を返す RecipeService を実装してみました。
- /api/recipes
- /api/recipes/{recipeId}
の2つのエンドポイントを利用しています。
(※ Angular v21.0.0で実装しています。HttpResourceはまだexperimentalなので、今後APIが変わって実装が動かなくなる可能性もあることに留意してください)
@Injectable({ providedIn: 'root' }) export class RecipeService { private readonly httpClient = inject(HttpClient); private readonly recipeId = signal(''); private readonly listRecipesResource = httpResource<RecipeModel[]>(() => '/api/recipes', { defaultValue: [], }); private readonly getRecipeResource = httpResource<RecipeModel>(() => { const recipeId = this.recipeId(); if (recipeId === '') { return undefined; } return `/api/recipes/${recipeId}`; }); public listRecipes(): Signal<RecipeModel[]> { return computed(() => this.listRecipesResource.value()); } public getRecipe(recipeId: string): Signal<RecipeModel | undefined> { this.recipeId.set(recipeId); return computed(() => { return this.getRecipeResource.value(); }) } constructor() { effect(() => { if (this.listRecipesResource.status() === 'error') { this.handleError(this.listRecipesResource.error() as HttpErrorResponse); } }); effect(() => { if (this.getRecipeResource.status() === 'error') { this.handleError(this.getRecipeResource.error() as HttpErrorResponse); } }); } private handleError(error: HttpErrorResponse) { if (error.status === 0) { console.log('An error occurred:', error.error.message); } else { console.log(`Backend returned code ${error.status}, reason: ${error.error.message}`); } return throwError(() => new Error('Something bad happened; please try again later.')); } }
HttpResourceをそのまま露出させてもよかったのですが、Component側では単純なSignalだけを扱うようにしたいと思い、メソッドで隠蔽しています。(この辺はベストプラクティスがまだよくわかっていません...)
初期値問題
今回つきまとったのが、流れてくる初期値にどう対応するかです。何も意識しないと、HttpResourceの初期値としてundefinedが流れてきます。
List APIの場合は簡単で、 defaultValue を設定することでundefinedからおさらばできます。普通のSignalでいう initialValue に相当します。
private readonly listRecipesResource = httpResource<RecipeModel[]>(() => '/api/recipes', { defaultValue: [], });
問題はGet APIです。今回は呼び出し元から受け取った recipeId をSignalにsetし、それをトリガーにAPIを叩くようにしているので、下手に recipeId の初期値を設定すると不要なリクエストが飛びかねないと思っていました。
しかしHttpResourceでは、URLのstringを導出する関数でnullやundefinedを返せば、リクエストが実行されないようです。ここで初期値であればリクエストを飛ばさないようにすることができました。
private readonly recipeId = signal(''); private readonly getRecipeResource = httpResource<RecipeModel>(() => { const recipeId = this.recipeId(); if (recipeId === '') { return undefined; } return `/api/recipes/${recipeId}`; });
effectでのエラーハンドリング
HttpResourceを叩くときのエラーハンドリングをどこでやるのか迷いましたが、これはエラーメッセージを返すような副作用を伴うので、 effectの中で行っています。
HttpResourceでは error() を叩くことでエラーレスポンスを受け取れるのですが、エラーがないときに叩くとこれもundefinedを返してくるのでまた面倒なことになります。そのため、 status() を叩いて今の状態を取得し、errorであればエラーハンドリング用のメソッドに飛ばす方法を取りました。
constructor() { effect(() => { if (this.listRecipesResource.status() === 'error') { this.handleError(this.listRecipesResource.error() as HttpErrorResponse); } }); effect(() => { if (this.getRecipeResource.status() === 'error') { this.handleError(this.getRecipeResource.error() as HttpErrorResponse); } }); }
Componentからの呼び出し
List APIはレシピ一覧画面のComponentから呼んでいます。ここでは取得したレシピ全件に対するキーワード検索機能をつけているので、 computedを利用してServiceからのSignalの戻り値を加工しています。
computedは読み取り専用であり、副作用のない純粋関数的な処理を想定して作られているため、本来はcomputedの中で副作用を起こすことはできません。しかし、HttpResourceが内部での非同期的なAPIリクエストを隠蔽し、Signalとしてアクセスできるようにしてくれています。
@Component({ selector: 'app-recipe-list', imports: [FormsModule, RouterLink], templateUrl: './recipe-list.component.html', styleUrls: ['./recipe-list.component.css'], }) export class RecipeList { private readonly recipeService = inject(RecipeService); private readonly recipes = this.recipeService.listRecipes(); protected readonly keyword = signal('') protected readonly filteredRecipes = computed(() => { return this.recipes().filter( recipe => recipe.name.toLowerCase().includes(this.keyword().toLowerCase()) ); }); }
これに対し、Get APIはレシピ詳細画面のComponentから利用し、effectの中からServiceを叩いています。
@Component({ selector: 'app-recipe-detail', imports: [], templateUrl: './recipe-detail.component.html', styleUrls: ['./recipe-detail.component.css'], }) export class RecipeDetail { private readonly recipeService = inject(RecipeService); protected readonly recipeId = input.required<string>() protected readonly recipe = signal<RecipeModel | null>(null); constructor() { effect(() => { const recipe = this.recipeService.getRecipe(this.recipeId()); this.recipe.set(recipe() || null); }); } }
なぜGet APIではeffectを使っているのかというと、Service側の getRecipe() の実装で recipeId Signalに値をsetしているためです。getRecipe() をcomputedの中で呼び出すと Writing to signals is not allowed in a computed と怒られます。
「それなら recipeId Signalを使うのをやめて、リクエストのたびにHttpResourceにrecipeIdを渡せばいいのでは?」と思ったのですが、現時点(Angular v21.0.0)でHttpResource内のurlコールバック関数に外から引数は渡せません。IDなどを渡したければSignalを経由させるしかなさそうです。
// url関数に引数を渡せない httpResource<RecipeModel>(url: () => string | undefined, options?: HttpResourceOptions<RecipeModel, unknown> | undefined):
なかなか使用例を見つけられず苦労しましたが、なんとか動作させられました。自分で試行錯誤して理解していくのも楽しいものですね。




