🙌

React Query Render の最適化

2023/02/15に公開

#2: React Query Data Transformations.で select オプションを説明した時に、レンダリングの最適化については既に多くの事を書いています。しかし、「データに何も変更がないのに、React Query はコンポーネントを2回再レンダリングするのか」という質問は、恐らく私が最も答える必要のあるものです。

isFetching Transition

前回の例で、このコンポーネントはTodoの数が変わった時だけ再レンダリングすると言ったのは、完全ではありません。

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

function TodosCount() {
  const todosCount = useTodosCount()

  return <div>{todosCount.data}</div>
}

バックグランドでの再取得の度に、このコンポーネントは以下のクエリ情報で2回再レンダリングします。

{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }

React Queryが各クエリ毎に多くのメタ情報を公開しており、isFetchingもその1つです。このフラグは、リクエストがin-flight中は常にtrueです。これはバックグラウンドで読み込み中のインジケータを表示したい時によく使われます。しかし、表示する必要がないのであれば、ちょっと不要です。

notifyOnChangeProps

このユースケースのために、notifyOnChangePropsオプションがあります。「propsのいずれかが変更された場合、オブザーバーに変更を通知してください」ということを React Query に伝えるために、各オブザーバー毎に設定することができます。このオプションに['data']を設定することによって、私達が求める最適化されたバージョンを見るけることができます。

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

ドキュメント内のoptimistic-updates-typescriptの例で確認できます。

Staying in sync

上記のコードはうまく機能しますが、非常に簡単に同期が取れなくなります。errorにも反応したい場合はどうしたら良いのでしょうか?もしくは、isLoadingフラグを使い始めたら? コンポーネントで実際に使用しているフィールドと同期したnotifyOnChangePropsリストを設定し続けなければいけません。これを忘れて、dataプロパティのみを observe し、errorも表示した場合、コンポーネントは再レンダリングされないので、役に立たないものになります。これはカスタムフックでハードコードした場合に非常に厄介です。なぜなら、カスタムフックはコンポーネントが実際にどのように使用するかを知らないからです。

export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])

function TodosCount() {
  // 🚨 we are using error, but we are not getting notified if error changes!
  const { error, data } = useTodosCount()

  return (
    <div>
      {error ? error : null}
      {data ? data : null}
    </div>
  )
}

冒頭の免責事項で述べたように、これは時折起こる「不要な再レンダリング」よりもずっと悪いことだと思います。もちろん、カスタムフックにオプションを渡すことはできますが、かなりマニュアル的なボイラプレートな感じがします。これを自動的に行う方法はあるのでしょうか?結論から言うとあります。

Tracked Queries

ライブラリへの初めての大きな貢献だったので、非常に誇らしく思います。notifyOnChangePropstrackedを設定した場合、React Queryはレンダリング時に使用しているフィールドを記録し続け、リストを計算するために使用します。これはリストを手動で指定するのと全く同じ方法で最適化しますが、リストの事を考える必要はありません。また、全てのクエリに対してグローバルに有効にすることもできます。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      notifyOnChangeProps: 'tracked',
    },
  },
})
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

この設定さえすれば、再レンダリングについて考える必要はありません。もちろん、使用状況を追跡するので多少のオーバーヘッドも発生するので、賢く利用しましょう。また、いつかの制限があるので、opt in 方式になっています。

  • Object Rest Destructuringを使用する場合、実質的に全てのフィールドをオブサーブすることになります。次のようなことはしないでください。
// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)
  • トラッキングされたクエリは「レンダリング中」のみ機能します。エフェクト内でフィールドにアクセスするだけの場合、追跡されることはないでしょう。これは依存関係配列が理由で、かなりのエッジケースです。
const queryInfo = useQuery(...)

// 🚨 will not corectly track data
React.useEffect(() => {
    console.log(queryInfo.data)
})

// ✅ fine because the dependency array is accessed during render
React.useEffect(() => {
    console.log(queryInfo.data)
}, [queryInfo.data])
  • トラッキングされたクエリはレンダリング毎にリセットされないので、一度フィールドをトラッキングしたら、オブザーバーのライフタイムまでトラッキングができます。
const queryInfo = useQuery(...)

if (someCondition()) {
    // 🟡 we will track the data field if someCondition was true in any previous render cycle
    return <div>{queryInfo.data}</div>
}

Structural sharing

React Queryのもう1つ重要なレンダリング最適化は、構造体の共有(structural sharing)です。あらゆるレベルで確実に参照同一性を保つことができます。例えば、以下のようなデータ構造があるとします。

[
  { "id": 1, "name": "Learn React", "status": "active" },
  { "id": 2, "name": "Learn React Query", "status": "todo" }
]

ここで、最初のTodoを done 状態に移行し、バックグランドで再取得を行なったとします。バックエンドから全く新しいjsonを取得することになります。

  [
-    { "id": 1, "name": "Learn React", "status": "active" },
+    { "id": 1, "name": "Learn React", "status": "done" },
     { "id": 2, "name": "Learn React Query", "status": "todo" }
  ]

ここで React Query は古いステートと新しいステートを比較し、可能な限り以前の状態を維持しようとします。例では、 todo を更新したので、配列は新しくなります。 id 1 のオブジェクトも新しくなりますが、 id 2 のオブジェクトは前の状態と同じ参照になります(id 2 は何も変更されていないので、新しい結果にそれをコピーするだけだからです)。

これは部分的にサブスクライブするために、セレクタを使用している時に非常に便利です。

// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing
const { data } = useTodo(2)

以前にも触れましたが、セレクタの場合、構造体の共有は2回行われます。一度目はqueryFnから返ってきた結果に何か変更があったかを特定するため、2回目はセレクタ関数の結果に対してです。特に非常に大きなデータセットの場合、 structural sharing はボトルネックになり得ます。また、これは JSON としてシリアライズ可能なデータに対してのみ動作します。この最適化が不要な場合は、任意のクエリでstructuralSharing: falseを設定することで無効にすることができます。もし、何が起きているかをもっと知りたい場合は、replaceEqualDeep testsを見てください。

Discussion