useQuery の queryFn における型推論の問題と解決策
1. 問題の概要
@tanstack/react-query の useQuery を使用した際に、APIからのレスポンスデータの型が正しく推論されず、any 型として扱われてしまう問題が発生しました。
これにより、以下のようなTypeScriptエラーが引き起こされていました。
プロパティ 'data' は型 'NonNullable<NoInfer<TQueryFnData>>' に存在しません。
パラメーター 'item' の型は暗黙的に 'any' になります。
これは、useQuery が返す data オブジェクトの型情報が失われていることが根本的な原因です。
2. 原因の分析: Before
問題が発生していたコードは以下の通りです。
src/features/items/hooks/useItems.ts
// Before: 問題があったコード
import { useQuery } from '@tanstack/react-query';
import { getItems, type ItemsResponse } from '@/libs/api/item';
export const useItems = () => {
return useQuery<ItemsResponse, Error>({
queryKey: ['items'],
queryFn: getItems, // API呼び出し関数を直接渡している
});
};
何が起きていたか:意図しない引数の受け渡し
エラーの核心は、useQueryがgetItems関数に意図しない引数を渡してしまうことにあります。
-
useQueryの内部仕様:useQueryは、queryFnに指定された関数を実行する際、必ずQueryFunctionContextオブジェクトを第一引数として渡します。// useQuery内部で実際に起こること const context = { queryKey: ['items'], signal: abortSignal, meta: undefined } const result = getItems(context) // ❌ getItemsに引数が渡される! -
getItems関数の定義: 一方で、getItems関数は引数を受け取らない設計になっています。const getItems = (): Promise<ItemsResponse> => { // 引数なしで実装されている return fetch('/api/items').then(res => res.json()) } -
型の不一致: この結果、以下のような型の不一致が発生します。
useQueryが呼び出す時の型: (context: QueryFunctionContext) => Promise<ItemsResponse> getItemsの実際の型: () => Promise<ItemsResponse> 引数の数が一致しない! (1個 vs 0個) -
型推論の失敗: この型の不一致により、TypeScriptは
getItemsの戻り値の型を正しく推論できず、安全策としてany型として扱ってしまいます。
JavaScriptの「余分な引数は無視される」仕様の罠
JavaScriptでは、関数定義よりも多くの引数を渡してもエラーにはならず、余分な引数は単純に無視されます。そのため実行時エラーは発生しませんが、TypeScriptの型チェックでは問題として検出されます。
3. 解決策: After
この問題を解決したコードは以下の通りです。
src/features/items/hooks/useItems.ts
// After: 修正後のコード
import { useQuery } from '@tanstack/react-query';
import { getItems, type ItemsResponse } from '@/libs/api/item';
export const useItems = () => {
return useQuery<ItemsResponse, Error>({
queryKey: ['items'],
// アロー関数でラップする
queryFn: () => getItems(),
});
};
なぜ解決したか:適切な引数の制御
queryFnにgetItemsを直接渡す代わりに、() => getItems()というアロー関数でラップすることで問題を解決しました。
-
useQueryとアロー関数の関係:useQueryは、アロー関数にQueryFunctionContextを渡します。// useQuery内部で実際に起こること const context = { queryKey: ['items'], signal: abortSignal, meta: undefined } const result = (() => getItems())(context) -
アロー関数の役割: アロー関数は
QueryFunctionContextを受け取りますが、その引数を使用せず、内部でgetItems()を引数なしで呼び出します。// アロー関数の実質的な動作 (context: QueryFunctionContext) => { // contextは受け取るが使用しない return getItems() // 引数なしで呼び出し } -
型の整合性: これにより、以下の型の整合性が保たれます。
useQueryが期待する型: (context: QueryFunctionContext) => Promise<ItemsResponse> アロー関数の型: (context: QueryFunctionContext) => Promise<ItemsResponse> 完全に一致!✅ -
正確な型推論: 型の整合性が保たれることで、TypeScriptは
getItemsの戻り値の型(Promise<ItemsResponse>)を正しく推論できます。
4. まとめ(ベストプラクティス)
useQueryのqueryFnに関数を渡す際は、以下のガイドラインに従うことを推奨します:
✅ 推奨パターン
// 引数を取らない関数の場合
queryFn: () => getItems()
// QueryFunctionContextを使用する場合
queryFn: ({ queryKey, signal }) => fetchItemsWithContext(queryKey, signal)
// 独自の引数を渡す場合
queryFn: () => getItems(customParams)
❌ 避けるべきパターン
// 引数を取らない関数を直接渡す
queryFn: getItems // 型の不一致が発生する可能性
アロー関数でラップすることで、引数の受け渡しを明示的に制御し、型の整合性を保つことができます。これは単なる回避策ではなく、コードの意図を明確にし、将来的な変更に対しても安全性を保つための良い設計判断です。
Discussion