🔎

useQuery の queryFn における型推論の問題と解決策

に公開

1. 問題の概要

@tanstack/react-queryuseQuery を使用した際に、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呼び出し関数を直接渡している
  });
};

何が起きていたか:意図しない引数の受け渡し

エラーの核心は、useQuerygetItems関数に意図しない引数を渡してしまうことにあります。

  1. useQueryの内部仕様: useQueryは、queryFnに指定された関数を実行する際、必ずQueryFunctionContextオブジェクトを第一引数として渡します。

    // useQuery内部で実際に起こること
    const context = { 
      queryKey: ['items'], 
      signal: abortSignal,
      meta: undefined 
    }
    const result = getItems(context) // ❌ getItemsに引数が渡される!
    
  2. getItems関数の定義: 一方で、getItems関数は引数を受け取らない設計になっています。

    const getItems = (): Promise<ItemsResponse> => {
      // 引数なしで実装されている
      return fetch('/api/items').then(res => res.json())
    }
    
  3. 型の不一致: この結果、以下のような型の不一致が発生します。

    useQueryが呼び出す時の型: (context: QueryFunctionContext) => Promise<ItemsResponse>
    getItemsの実際の型:        () => Promise<ItemsResponse>
    
    引数の数が一致しない! (1個 vs 0個)
    
  4. 型推論の失敗: この型の不一致により、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(),
  });
};

なぜ解決したか:適切な引数の制御

queryFngetItemsを直接渡す代わりに、() => getItems()というアロー関数でラップすることで問題を解決しました。

  1. useQueryとアロー関数の関係: useQueryは、アロー関数にQueryFunctionContextを渡します。

    // useQuery内部で実際に起こること
    const context = { queryKey: ['items'], signal: abortSignal, meta: undefined }
    const result = (() => getItems())(context)
    
  2. アロー関数の役割: アロー関数はQueryFunctionContextを受け取りますが、その引数を使用せず、内部でgetItems()を引数なしで呼び出します。

    // アロー関数の実質的な動作
    (context: QueryFunctionContext) => {
      // contextは受け取るが使用しない
      return getItems() // 引数なしで呼び出し
    }
    
  3. 型の整合性: これにより、以下の型の整合性が保たれます。

    useQueryが期待する型: (context: QueryFunctionContext) => Promise<ItemsResponse>
    アロー関数の型:       (context: QueryFunctionContext) => Promise<ItemsResponse>
    
    完全に一致!✅
    
  4. 正確な型推論: 型の整合性が保たれることで、TypeScriptはgetItemsの戻り値の型(Promise<ItemsResponse>)を正しく推論できます。

4. まとめ(ベストプラクティス)

useQueryqueryFnに関数を渡す際は、以下のガイドラインに従うことを推奨します:

✅ 推奨パターン

// 引数を取らない関数の場合
queryFn: () => getItems()

// QueryFunctionContextを使用する場合
queryFn: ({ queryKey, signal }) => fetchItemsWithContext(queryKey, signal)

// 独自の引数を渡す場合
queryFn: () => getItems(customParams)

❌ 避けるべきパターン

// 引数を取らない関数を直接渡す
queryFn: getItems // 型の不一致が発生する可能性

アロー関数でラップすることで、引数の受け渡しを明示的に制御し、型の整合性を保つことができます。これは単なる回避策ではなく、コードの意図を明確にし、将来的な変更に対しても安全性を保つための良い設計判断です。

Discussion