💨

データフェッチはuseEffectの出番じゃないなら、結局何を使えばいいんだ

2022/07/02に公開約6,000字

ショートアンサー

React 18 からのフックである、useSyncExternalStore を使えばいいようです。

useEffect がまったくだめだというわけではありません。
※ クライアントサイドレンダリングのみを考えています。サーバーサイドレンダリングを考慮すると違った答えになるかもしれません。

サンプルコード

次のような useData フックを作ってみます。
JSON API の GET レスポンスを返すシンプルなものです。

実験をしやすいように、リクエスト URL を変えるボタンを置いてあります。

import { useEffect, useState } from "react"

export function SearchResults() {
  const [id, setID] = useState(1)

  const todo = useData(`https://jsonplaceholder.typicode.com/todos/${id}`)

  return (
    <div>
      <button
        type="button"
        onClick={() => {
          setID((id) => id + 1)
        }}
      >
        Increment ID (current: {id})
      </button>

      <pre>{JSON.stringify(todo, null, 2)}</pre>
    </div>
  )
}

function useData<T>(url: string): T | undefined {
  // ...
}

useEffect

次のリンクから持ってきたものです。

https://beta.reactjs.org/learn/you-might-not-need-an-effect#fetching-data

fetch の abort をしなくていいんだろうかと思いつつ、サンプルそのままです。
型や変数名は変えています。

function useData<T>(url: string): T | undefined {
  const [data, setData] = useState<T>()

  useEffect(() => {
    let ignore = false

    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) {
          setData(data)
        }
      })

    return () => {
      ignore = true
    }
  }, [url])

  return data
}

リンク先の記事では、この簡易なサンプルを紹介しつつも、Next.js などのフレームワークが用意した仕組みを使うとよいと述べています(React はフレームワークではないので)。
なので、「データフェッチにおいて effect は必要(必須)ではない」ようです。

useSyncExternalStore

独自に考えたものです。

function useData<T>(url: string): T | undefined {
  const data$ = useRef<T>()

  const subscribe = useCallback(
    (onStoreChange: () => void): (() => void) => {
      const controller = new AbortController()

      fetch(url, { signal: controller.signal })
        .then((res) => res.json())
        .then((data) => {
          data$.current = data

          onStoreChange()
        })

      return () => {
        controller.abort()
      }
    },
    [url]
  )

  return useSyncExternalStore(subscribe, () => data$.current)
}

useSyncExternalStore が相手にする external store は、ここでは Promise (fetch(url).then((res) => res.json())) です。

第 1 引数 subscribe は、external store の変化を通知するコールバック onStoreChange を登録する関数です。
ここでは Promise の解決したときが通知タイミングなので、then 内でコールバックを呼んでいます。

subscribe の参照が変わると再レンダリングが生じるため(ここでは無限ループにつながります)、useCallback で参照の同一性を保っています。

第 2 引数 getSnapshot は、external store のそのときの値を返す関数です。
ここでは、Promise 解決後の値を Promise から同期的に取得できないため、ref を使ってなんとかしています。

説明(思考ダンプ)

「データフェッチは useEffect の出番じゃない」って誰が言ってるんだ

Dan Abramov さんです。

まず、彼は「useEffect は React ツリー外と props/state をシンクロする道具だ」と述べています。

https://overreacted.io/a-complete-guide-to-useeffect/#synchronization-not-lifecycle

useEffect lets you synchronize things outside of the React tree according to our props and state.

API リクエストを 1 回だけ送りたいとか、パラメーターが変わったのに反応して送りたいとか、そういう使い方をすべきではない。
「props/state と API レスポンスとをシンクロする」と考えなさいということですね。

しかしながら、後半では 「データフェッチは正確にはシンクロ問題ではない」 と述べています。

https://overreacted.io/a-complete-guide-to-useeffect/#raising-the-bar

So far, useEffect is most commonly used for data fetching. But data fetching isn’t exactly a synchronization problem.

「データフェッチは useEffect の出番じゃない」ということです。

次の記事でも、同じようなことを述べています。

https://beta.reactjs.org/learn/you-might-not-need-an-effect#fetching-data

両者とも「たしかに簡単だから使ってもいいけれど、別の手法がベターだ」というスタンスのようです。

「データフェッチは正確にはシンクロ問題ではない」に納得する

キーは、シンクロ問題とは React ツリー外 と props/state を相手にしているということです。

useEffect 版のサンプルコードをよく見ると、useData フック内で url(state である id から導出したものなので、state です)とシンクロしているのは、「React ツリー外のもの」ではありません。
React ツリーのど真ん中の state である、data です。

再掲
function useData<T>(url: string): T | undefined {
  const [data, setData] = useState<T>()

  useEffect(() => {
    let ignore = false

    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) {
          setData(data)
        }
      })

    return () => {
      ignore = true
    }
  }, [url])

  return data
}

このことが、「正確には」シンクロ問題ではないと言わしめていると考えました。

じゃあデータフェッチは何者なんだ

External store なんだと思います。

たとえば、以下の useSyncExternalStore のサンプルコードでは、navigator.onLine API を external store として扱っています。

https://beta.reactjs.org/learn/you-might-not-need-an-effect#subscribing-to-an-external-store
抜粋
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

External な理由は、React の状態管理とは独立した状態システムの上にあるからでしょう。
同様に、HTMLMediaElement や Intersection Observer API も external store になるでしょう。

データフェッチの結果も、ネットワーク I/O という独立した状態システムの上にあると考えられそうです。
つまり、external store と考えて、差し支えなさそうです。

データフェッチを XMLHttpRequest で行ったとすると、addEventListener でレスポンスを取得することになり形の上でも navigator.onLine API と同じになるため、データフェッチはやはり external store でしょう。

とはいえ useSyncExternalStore でも書き味大して変わらないな

そうです。
結局は、SWR や React Query を足したり、もしくは Next.js などのフレームワークに従ったりすべきです。

「このページを訪れたら」は useEffect を使う以外ないのではないか

そう思っています。
ページビューを Google Analytics に送るような場合ですね。
次の記事にサンプルコードがあります。

https://beta.reactjs.org/learn/synchronizing-with-effects#sending-analytics

ただし、これはユーザーアクションを契機にした処理であって、シンクロ問題ではありません。
シンクロ問題なのであれば、効率はさておき何度同じページビューを送っていいはずですが、そうではないからです。

現実に useEffect を使う以外ないのは、React にそういう機能がまだ or 意図的にないからだと思います。

公式に言及があったかわかりませんが(知っていれば教えてください🙏)、個人的には意図的なんだと思います。
「ページを訪れたら」はルーティングとともに起きる話で、ルーティングは Next.js などのフレームワークや React Router などのサードパーティーに任されていることから、React 本体としては関与しない箇所なのではと。

まとめ

React でのデータフェッチは、useEffect でもいいけど、専用に作られた useSyncExternalStore を使うのがいい。
もっといいのは、データフェッチ用ライブラリーなどを使うこと。

useEffect は便利だしなくせない。

Discussion

ログインするとコメントできます