React Query のデータ変換

2023/02/13に公開2

「Things I have to say about react-query」の第2回へようこそ。ライブラリやその周辺のコミュニティにより関わるようになってから、人々がよく尋ねるパターンに気付きました。第一回目は一般的で重要なタスクについてです。それは Data Transformation(データ変換)です。

Data Transformation

正直に言うと、私たちのほとんどはGraphQLを使用していません。もし使っているなら、あなたが望んだフォーマットでデータをリクエストする贅沢ができるので、非常に幸運な事です。

あなたがRESTを使用しているなら、バックエンドが返すものに制約されます。では、React Queryを用いたデータ変換はどこでどのようにするのがベストなのでしょうか?ソフトウェア開発における唯一価値のある答え「It depends」は、ここでも当てはまります。

本記事では、3+1のアプローチを長所/短所と共に紹介します。

0. On the backend

余裕があるなら、これはお気に入りのアプローチです。バックエンドが要求する構造体でデータを返すなら、私達は何もする必要がありません。多くのケース(例: 公開されている REST APIを使用する)で、非現実的に聞こえるかもしれませんが、エンタープライズアプリケーション (EA) では十分可能な事です。あなたがバックエンドを制御して、かつ期待するユースケースのためのデータを返すエンドポイントがあるなら、期待する方法でデータを提供することを好むでしょう。

長所: フロントエンド上の処理が不要
短所: 必ずしも可能なアプローチではない

1. In the queryFn

queryFnuseQueryに渡す関数です。Promiseを返すことを期待し、Promiseが返却するデータはクエリキャッシュに格納されます。しかし、バックエンドが提供する構造体で絶対にデータを返却しなければならないという意味ではありません。その前にtransformすることができます。

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  const data: Todos = response.data

  return data.map((todo) => todo.name.toUpperCase())
}

export const useTodosQuery = () =>
  useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

フロントエンド上で、「あたかもバックエンドから送られてきたように」データを扱うことができるのです。あなたのコード上では、実際に大文字ではないTodo名を扱うことはないでしょう。また、元の構造体にアクセスする事はできません。react-query-devtoolsを見ると、変換後の構造体を確認できるでしょう。ネットワークトレースを見ると、元の構造体を確認できます。これは混乱するかもしれないので、覚えておいてください。

また、ここではreact-queryによる最適化はありません。フェッチが実行される度、あなたの変換処理が実行されます。その処理が高く付くなら、別の選択肢を検討してください。また、企業によっては、データ取得を抽象化した共有のAPIレイヤーを用意している事もあるので、変換処理を行うためにこのレイヤーにアクセスしないといけないかもしれません。

長所

  • コロケーションという意味で、非常に「バックエンドに近い」

短所

  • フェッチ毎に実行される
  • 自由に変更できない共有APIレイヤーの場合、実現できない

その他

  • 変換された構造体はキャッシュに格納されるので、元の構造体にアクセスする事ができない。

2. In the render function

第1回のアドバイスとしてカスタムフックを作成するなら、そこで変換処理を簡単に行うことができます。

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  return response.data
}

export const useTodosQuery = () => {
  const queryInfo = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

  return {
    ...queryInfo,
    data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  }
}

このままではフェッチ関数が実行される度に、カスタムフックが実行されるだけでなく、実際には全てのレンダー(データ取得を伴わないレンダーでさえ)でも実行されます。これは全く問題ないですが、問題になるようでしたら、useMemoで最適化することができます。依存関係をできるだけ狭く定義するように注意してください。queryInfo内のdataは、実際の変更が起きる(その場合、変換処理を再実行したい)まで、参照的に安定しますが、queryInfo自体はそうではありません。依存関係にqueryInfoを追加すると、レンダリングの度に変換処理が実行されます。

export const useTodosQuery = () => {
  const queryInfo = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

  return {
    ...queryInfo,
    // 🚨 don't do this - the useMemo does nothing at all here!
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo]
    ),

    // ✅ correctly memoizes by queryInfo.data
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo.data]
    ),
  }
}

データ変換処理を組み合わせるロジックをカスタムフックに追加している場合、それは良いオプションです。データがundefinedになる可能性があることに注意し、このデータを使用する場合はオプショナルチェーンを使用します。

長所

  • useMemoで最適化できる

短所

  • 少し複雑な構文になる
  • データがundefinedになる可能性がある

その他

  • 正確な構造体をdevtoolで調べる事ができない

using the select option

v3では、組み込むのセレクタが導入され、データ変換にも使用することができるようになりました。

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.map((todo) => todo.name.toUpperCase()),
  })

セレクタはdataが存在した場合のみ呼ばれるので、undefinedを気にする必要がありません。
インライン関数ですので、上記のようなセレクタは同一性が変化(上記の例はインライン関数)します。その結果、レンダリング毎に実行されます。変換処理が高く付く場合、useCallbackを使用する、もしくは安定した関数参照に変換処理部分を抽出することでメモ化する事ができます。

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ uses a stable function reference
    select: transformTodoNames,
  })

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ memoizes with useCallback
    select: React.useCallback(
      (data: Todos) => data.map((todo) => todo.name.toUpperCase()),
      []
    ),
  })

更に select オプションを使えば、データの一部だけを購読する事も可能です。これがこのアプローチを真にユニークなものにしています。次のような例を考えてみましょう。

export const useTodosQuery = (select) =>
  useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select })

export const useTodosCount = () => useTodosQuery((data) => data.length)
export const useTodo = (id) =>
  useTodosQuery((data) => data.find((todo) => todo.id === id))

ここでは、useTodosQueryにカスタムセレクターを渡すことによってuseSelectorのようなAPIを作る事ができました。カスタムフックは今まで通り、selectを渡さない場合はundefinedになるので、ステート全体が返されます。

しかし、セレクターを渡した場合、今度はセレクタ関数の結果のみを購読することになります。これは非常に強力で、Todoの名前を更新しても、useTodosCountを介してカウントを購読しているだけのコンポーネントは再レンダリングされないことを意味します。カウントは変更されていないので、react-queryはこのオブザーバーに更新を通知しないことを選択できます(ここでは少し簡略化されており、技術的には完全ではないので注意してください。レンダリングの最適化については第3回でより詳しく説明します)。

長所

  • ベストな最適化
  • 部分的な購読が可能

その他

  • 構造体はオブザーバー毎に異なる可能性がある
  • 構造体の共有が2度行われる(詳細は第3回でより詳しく話します)

Discussion