https://zenn.dev/kazuma1989/articles/a30ba6e29b5b4c

ショートアンサー

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

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

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 で参照の同一性を保っています。