React におけるローディングの状態管理について

エンジニアの @_tohashi です。freee developers adevent calender 5 日目をやっていきます。

React などを使用した UI コンポーネントの実装、特に状態をどこで管理するかというのは実装者やアプリケーションの要件によって分かれがちなポイントであると思っていて、例としてはフォームの入力値、ダイアログの開閉、スピナーの表示などが挙げられます。各種ドキュメントや Issue, Example を見ても様々な流派があり、結局のところ Redux の FAQ にもあるようにこれが正解といったものはなくモデリングや要件に応じて適宜判断すべき話ではあるのですが、チーム開発においてはある程度方針を統一しておく必要があるでしょう。

本記事ではそうしたコンポーネントの状態管理のうち、特に非同期処理が絡んできて複雑になりがちなローディングについて自分の経験をもとに実装のバリエーションを紹介していきます。1

コンポーネント単体で完結

まずはシンプルな例で考えてみます。コンポーネントがマウントされたら API リクエストを送り、レスポンスが得られたらその内容を元にコンテンツをレンダリングします。 読み込み中であることがユーザーに伝わるように、レスポンスが返ってくるまでの間コンテンツ部分にスピナーを表示しておきます。

class App extends React.Component {
  componentDidMount() {
    API.fetchData().then(res =>
      this.setState({ data: res.data })
    );
  }

  render() {
    return (
      <div className="App">
        {this.state.data ? (
          <Content data={this.state.data} />
        ) : (
          <Spinner />
        )}
      </div>
    );
  }
}

この場合は簡単です。 this.state.data の有無で通信中かどうかを判断できるので、無ければ <Spinner /> を表示、リクエストが完了して State が更新されれば切り替わります。

コンポーネント内でユーザーのアクションに応じて API リクエスト送る

次の例です。コンポーネントのマウント直後に加え、ボタンクリックなどユーザーの操作に応じて API リクエストを送り得るケースです。具体例としてはページネーションや無限スクロールの追加読み込みといったものが該当します。

class App extends React.Component {
  state = { data: null, page: 1, loading: true };

  componentDidMount() {
    API.fetchData(this.state.page).then(res =>
      this.setState({ data: res.data, loading: false })
    );
  }

  handleClick(page) {
    this.setState({ page, loading: true }, () =>
      API.fetchData(page).then(res =>
        this.setState({ data: res.data, loading: false })
      )
    );
  }

  render() {
    return (
      <div className="App">
        {this.state.loading ? (
          <Spinner />
        ) : (
          <Content data={this.state.data} />
        )}
        <Pager
          page={this.state.page}
          onClick={this.handleClick.bind(this)}
        />
      </div>
    );
  }
}

ローディング中であるかどうかを判定するために、loading という State を持たせて API リクエスト時に切り替えるようにしました。リクエストパラメータが常に変わる(リロードなどがない)場合は this.state.pagethis.state.data のページが一致しているかどうかで判定するという方法もあります。

ローディングの状態を Store の State として扱う

ここまではコンポーネント単体で見てきましたが、実際は Redux などの状態管理ライブラリと合わせて使うケースがほとんどです。非同期処理を ActionCreator 側で行うのであれば、これまでの例のように Component 側から直接 API レスポンスに触れることはできません。いくつかの Redux のサンプルを参考に、loading の状態も Store の State として扱うようにしてみます。

// ActionCreator 非同期処理を扱うため redux-thunk を使用
const fetchData = page => {
  return async dispatch => {
    dispatch({ type: 'REQUEST_FETCH_DATA' });
    const res = await API.fetchData(page);
    dispatch({
      type: 'SUCCESS_FETCH_DATA',
      payload: res.data
    });
  };
};

const dataReducer = (
  state = { data: null, loading: false },
  { type, payload }
) => {
  switch (type) {
    case 'REQUEST_FETCH_DATA':
      return { ...state, loading: true };
    case 'SUCCESS_FETCH_DATA':
      return { ...state, loading: false, data: payload };
    default:
      return state;
  }
};

class App extends React.Component {
  state = { page: 1 };

  componentDidMount() {
    this.props.dispatch(fetchData(this.state.page));
  }

  handleClick(page) {
    this.setState({ page }, () => {
      this.props.dispatch(fetchData(page));
    });
  }

  render() {
    return (
      <div className="App">
        {this.props.loading ? (
          <Spinner />
        ) : (
          <Content data={this.props.data} />
        )}
        <Pager
          page={this.state.page}
          onClickNextButton={this.handleClick.bind(this)}
        />
      </div>
    );
  }
}

connect(state => state.dataReducer)(App);

この例では API リクエストを送ってレスポンスを受け取るまでに 2 つの Action を dispatch しています。

  1. API リクエストを送る REQUEST_FETCH_DATA
  2. API リクエストが返ってきた SUCCESS_FETCH_DATA

REQUEST_FETCH_DATA が dispatch されたら store の loadingtrue にし、SUCCESS_FETCH_DATA が dispatch されたら false にすると共に payload をセットします。コンポーネントはローディングの状態を持たず、受け取った Props に応じて <Spinner /> の表示を切り替えるのみとなりました。

本記事では割愛しますが、実際はさらに `ERROR_FETCH_DATA' のような action を追加してそこでエラーハンドリングを行うことになります。

リストビューなどで複数のローディングを扱う

ここまでは一つのローディングだけを扱ってきましたが、複数のローディングを扱うケースについても考えてみます。リストビュー上のアイテムを個別にインラインで編集し、PUT リクエストを送るような UI です。ローディングの状態を store 側で管理する場合は以下のような ActionCreator と Reducer になります。

const fetchItems = () => {
  return async dispatch => {
    dispatch({ type: 'REQUEST_FETCH_ITEMS' });
    const res = await API.fetchItems();
    dispatch({
      type: 'SUCCESS_FETCH_ITEMS',
      payload: res.items
    });
  };
};

const updateItem = targetItemId => {
  return async dispatch => {
    dispatch({
      type: 'REQUEST_UPDATE_ITEM',
      payload: { itemId: targetItemId }
    });
    await API.updateItem(itemId);
    dispatch({
      type: 'SUCCESS_UPDATE_ITEM',
      payload: { itemId: targetItemId }
    });
  };
};

const itemReducer = (
  state = {
    items: [],
    fetchingItems: false,
    updatingItems: []
  },
  { type, payload }
) => {
  switch (type) {
    case 'REQUEST_FETCH_ITEMS':
      return { ...state, fetchingItems: true };
    case 'SUCCESS_FETCH_ITEMS':
      return {
        ...state,
        fetchingItems: false,
        items: payload
      };
    case 'REQUEST_UPDATE_ITEM':
      return {
        ...state,
        updatingItems: [
          ...state.updatingItems,
          { itemId: payload.itemId }
        ]
      };
    case 'SUCCESS_UPDATE_ITEM':
      return {
        ...state,
        updatingItems: state.updatingItems.filter(
          updatingItem =>
            updatingItem.itemId !== payload.itemId
        )
      };
    default:
      return state;
  }
};

リスト全体のローディングを fetchingItems, 個々のアイテムの更新のためのローディングを updatingItems の配列でそれぞれ管理しています。

ローディング後に発行したいイベントなどがある

非同期処理が完了したタイミングで、「保存しました」といった Notification を表示するケースです。Notification の表示判定は非同期処理中かどうかだけではなく、完了した直後という条件が加わるためコンポーネント側で loading の変更をチェックするようにします。

class Item extends React.Component {
  state = { loading: false, displayNotification: false };

  static getDerivedStateFromProps(props, state) {
    // ローディングが終わった
    if (state.loading && !props.loading) {
      return {
        displayNotification: true,
        loading: props.loading
      };
    }

    // ローディングが始まった
    if (state.loading !== props.loading) {
      return {
        displayNotification: false,
        loading: props.loading
      };
    }

    // loading に変化がなければ State の変化はなし
    return null;
  }

  render() {
    return (
      <div className="item">
        {this.state.loading ? <Spinner /> : item.name}
        {this.state.displayNotification ? (
          <Notification message="保存しました" />
        ) : null}
      </div>
    );
  }
}

ここでは再びコンポーネントの State に loading を持たせて、Props の loading に追従させています。React v16.3 から追加された getDerivedStateFromProps を使用してコンポーネントが Props を受け取る際に State と比較し、ローディングが終わったタイミング = state.loading && !props.loading であれば displayNotificationtrue にして <Notification /> を表示させます。2

これまでの例と同様に displayNotification を Store の State として扱い、Reducer 側で切り替えることも可能ですが、<Notification /> を時間経過やユーザーのアクションによって非表示にするケースなどを想定するとコンポーネントの状態として完結させた方がデータフローをシンプルにできそうです。

より大規模なアプリケーション

ローディングの状態を Store を State として扱う場合、規模が大きくなるにつれいくつか辛い点が出てきました。

  • 非同期処理ごとに REQUEST_XXXSUCCESS_XXX (と FAILED_XXX)を作るので Reducer が肥大化しがち
  • LoadingState のバケツリレー

前者は Reducer の切り分け、後者は Context API といった対策が考えられます。

const itemReducer = (
  state = { items: [] },
  { type, payload }
) => {
  switch (type) {
    case 'SUCCESS_FETCH_ITEMS':
    //...
    case 'SUCCESS_UPDATE_ITEM':
    //...
  }
};

const loadingReducer = (
  state = { fetchingItems: false, updatingItems: [] },
  { type, payload }
) => {
  switch (type) {
    case 'REQUEST_FETCH_ITEMS':
    //...
    case 'SUCCESS_FETCH_ITEMS':
    //...
    case 'REQUEST_UPDATE_ITEM':
    //...
    case 'SUCCESS_UPDATE_ITEM':
    //...
  }
};

const LoadingContext = React.createContext({
  loading: false
});

function App() {
  return (
    <div className="App">
      <LoadingContext.Provider value={this.props.loading}>
        <ChildContent />
      </LoadingContext.Provider>
    </div>
  );
}

function GrandChildContent() {
  return (
    <LodingContext.Consumer>
      {loading =>
        loading ? <Spinner /> : <GreatGrandChildContent />
      }
    </LodingContext.Consumer>
  );
}

ローディングの状態をコンポーネントの State として扱う

考え方を少し変えてみます。ローディングの状態は非同期処理の状況によって変わるとは言え対象のコンポーネント内でしか参照されず、永続化されるようなこともありません。グローバルな Store の State として扱い毎回 Reducer を通すのは少し大げさかもしれません。

最初の例のようにloading はあくまでコンポーネントの State として扱いつつ、Redux のデータフローと組み合わせてみます。

// using redux-promise
const fetchData = async page => {
  const res = await API.fetchData(page);
  return {
    type: 'SUCCESS_FETCH_DATA',
    payload: res.data
  };
};

export const reducer = (
  state = { data: null },
  { type, payload }
) => {
  switch (type) {
    case 'SUCCESS_FETCH_DATA':
      return { ...state, data: payload };
    default:
      return state;
  }
};

class App extends React.Component {
  state = { page: 1, loading: false };

  componentDidMount() {
    this.fetchData();
  }

  handleClick(page) {
    this.setState({ page }, () => this.fetchData(page));
  }

  async fetchData() {
    this.setState({ loading: true });
    await this.props.fetchData(this.state.page);
    this.setState({ loading: false });
  }

  render() {
    return (
      <div className="App">
        {this.state.loading ? (
          <Spinner />
        ) : (
          <Content data={this.props.data} />
        )}
        <Pager
          page={this.state.page}
          onClickNextButton={this.handleClick.bind(this)}
        />
      </div>
    );
  }
}

connect(
  state => state.reducer,
  dispatch => ({
    fetchData: page => dispatch(fetchData(page))
  })
)(App);

redux-promise を使用して非同期な ActionCreator は Promise オブジェクトを返すようにしています。コンポーネント側で async/await を使用することで非常にシンプルに書くことができました。この方法であればリストビューや非同期処理後のアクションも同様に実装できそうです。

Suspense を使う

将来的な話になってきますが、今後 React に実装予定の機能である Suspense を使うことでローディングの状態を State に持つことなく書けるようになりそうです。

// API は今後変わる可能性があります
import { unstable_createResource } from 'react-cache';

const Resource = unstable_createResource(fetchData);

function App() {
  const [page, setPage] = React.useState(1);
  return (
    <div className="App">
      <React.Suspense
        maxDuration={100}
        fallback={<Spinner />}
      >
        <Content data={Resource.read(page)} />
      </React.Suspense>
      <Pager
        page={page}
        onClickNextButton={page => setPage(page + 1)}
      />
    </div>
  );
}

レンダリング時に API リクエストを送り、 maxDuration ms 以内に返ってこなければ fallback に指定した <Spinner /> が表示されます。基本的に Fetching 専用であったり、キャッシュ管理をミスるとレンダリング毎に無限に API リクエストを飛ばしてしまうリスクがあったりと、当然銀の弾丸というわけではありませんが、Production Ready が楽しみな新機能の一つですね。

最後に

といった感じで、ローディングの状態管理一つとっても色々なアプローチが考えられます。僕自身は最初はデータフローを揃えたく、また ActionCreator が Promise を返すことを何となく避けていたため Store の State として管理する派でしたが、やはり Reducer の肥大化や UIState のバケツリレーの部分で辛くなってきたため現在ではコンポーネントの State として完結させる方針に落ち着きました。既に React や Vue でアプリケーションをゴリゴリ書いている方には自明な話も多かったかもしれませんが、意外とこういうのが暗黙知になりがちなので今回こうして言語化してみました。ローディングの実装で悩んでいる方の参考になれば幸いです。

明日は freee のサービス基盤を支える terashi です。お楽しみに!


  1. 画像やモジュールの遅延ロード、グラフの描画などの非同期処理などもありますが今回は API リクエストに関わるもののみ扱います。

  2. v16.2 以前であれば componentWillReceiveProps を使用して同様の処理を書くことができます。