48
Help us understand the problem. What are the problem?

posted at

updated at

Organization

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

はじめに

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

実装

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等で包んでおく必要があります。

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>
  );
};

デモ

おわりに

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

追記 2021/08/20

dispatchのワーニングは、今後でなくなります(参考)。上記コードではdispatchSafeは不要になり、単にdispatchとするだけで良くなります。 嘘でした、新しいfetchが走った時に古いものが上書きしないように必要でした。

追記 2022/06/25

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
48
Help us understand the problem. What are the problem?