React hooksでデータ取得をするuseFetchのちゃんとした実装

## はじめに

React 16.8でhooksが導入されましたが、Suspense for data fetchingやreact-cacheが登場するまでは、データ取得のベストプラクティスは曖昧です。しかし、キャッシュさえ重要でなければuseEffectで比較的簡単にFetch APIのフックを実装することができます。

## 実装

```javascript
import { useEffect, useReducer } from 'react';

const initialState = {
  loading: false, // データ取得中はtrueに設定される
  error: null,    // データ取得でエラーになると設定される
  data: null,     // データ取得結果が設定される
};

const reducer = (state, action) => {
  switch (action.type) {
    case 'init': // 初期状態に戻す
      return initialState;
    case 'start': // データ取得を開始する
      return { ...state, loading: true };
    case 'data': // データ取得が正常終了する
      return { ...state, loading: false, data: action.data };
    case 'error': // データ取得がエラー終了する
      return { ...state, loading: false, error: action.error };
    default: // それ以外は起こりえないのでバグ検知のためthrow
      throw new Error('no such action type');
  }
};

const defaultOpts = {}; // デフォルトでは空オプション
const defaultReadBody = body => body.json(); // デフォルトではjsonとしてparseする

export const useFetch = (
  url,
  opts = defaultOpts,
  readBody = defaultReadBody,
) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  useEffect(() => {
    let dispatchSafe = action => dispatch(action); // cleanupで無効にするため
    const abortController = new AbortController(); // cleanupでabortするため
    (async () => {
      if (!url) return;
      dispatchSafe({ type: 'start' });
      try {
        const response = await fetch(url, {
          ...opts,
          signal: abortController.signal,
        });
        if (response.ok) {
          const body = await readBody(response);
          dispatchSafe({ type: 'data', data: body });
        } else {
          const e = new Error(`Fetch failed: ${response.statusText}`);
          dispatchSafe({ type: 'error', error: e });
        }
      } catch (e) {
        dispatchSafe({ type: 'error', error: e });
      }
    })();
    const cleanup = () => {
      dispatchSafe = () => null; // we should not dispatch after unmounted.
      abortController.abort();
      dispatch({ type: 'init' });
    };
    return cleanup;
  }, [url, opts, readBody]);
  return state;
};
```

## 使い方

注意点は、useFetchの引数はuseEffectのdepsに渡されるため参照等価性が重要なことです。関数内で使う場合は、通常、useMemo等で包んでおく必要があります。

```jsx
import React, { useMemo } from 'react';

const MyComponent = ({ url, token }) => {
  const opts = useMemo(() => ({
    headers: {
      Authorization: `Bearer ${token}`,
    },
  }, [token]);
  const { loading, error, data } = useFetch(url, opts);
  return (
    <div>
      {loading && <span>Loading...</span>}
      {error && <span>Failed to fetch</span>}
      {data && <div>{JSON.stringify(data)}</div>}
    </div>
  );
};
```

## デモ

https://codesandbox.io/s/q84ljk7ym6

## おわりに

本実装はAbortControllerを使ってコンポーネントのライフサイクルに合わせてfetchをキャンセルするため安全に使えます。一方で、キャッシュはないため、複数のコンポーネントで共通したデータ取得の場合も、それぞれで取得することになってしまいます。そのようなケースはreact-cacheに任せるとして、使い分けることになると思います。

## 追記 2021/08/20

dispatchのワーニングは、今後でなくなります([参考](https://github.com/reactwg/react-18/discussions/82))。~~上記コードでは`dispatchSafe`は不要になり、単に`dispatch`とするだけで良くなります。~~ 嘘でした、新しいfetchが走った時に古いものが上書きしないように必要でした。

## 追記 2022/06/25

このuseFetchの実装はちゃんとはしているのですが、今ではベストプラクティスからは外れています。useEffectによるfetchの発火はレンダリング後になるためタイミングが遅い、かつ、無駄なレンダリングになる場合があるためです。Suspense指向の `useFetch` の実装としては例えば [react-hooks-fetch](https://github.com/dai-shi/react-hooks-fetch) をご参照ください。