Reduxを利用した非同期処理中のローディング機能実装

JavaScript

今回ローディング機能を実装するアプリケーションの仕様について

今回はToDoリスト管理アプリを例に、ローディング機能の実装方法を紹介します。
当該アプリケーションは、非同期処理の実行状況に応じてActionの発行とRedux Store内のState(以下State)を更新する実装になっています。

たとえばToDo一覧を非同期で取得するgetTodosという処理は、非同期処理の実行状況に応じて以下のActionを発行します。

  • 実行中(pending)の時: “getTodos/pending”
  • 成功(fulfilled)の時: “getTodos/fulfilled”
  • 失敗(rejected)の時: “getTodos/rejected”

ActionTypeとStateの対応は以下の通りです。

// ActionTypeが『getTodos/pending』の時

{
  entities: {
    todos: {
      allIds: [],
      byId: {}
    }
  },
  requests: {
    getTodos: {
      status: 'REQUEST'
    }
  }
}

// ActionTypeが『getTodos/fulfilled』の時

{
  entities: {
    todos: {
      allIds: [...],
      byId: {...}
    }
  },
  requests: {
    getTodos: {
      status: 'SUCCESS'
    }
  }
}

// ActionTypeが『getTodos/rejected』の時

{
  entities: {
    todos: {
      allIds: [],
      byId: {}
    }
  },
  requests: {
    getTodos: {
      status: 'FAILURE'
    }
  }
}

上記のState実装の詳細解説はRedux Toolkitを利用して非同期処理のステータスをStateで管理するで紹介しています。

今回実現すること

ToDoリスト一覧画面を表示する際、ToDoの取得が完了するまでの間ローディング状態を表示させるようにします。

今回紹介する実装パターンについて

今回は『Selector経由でStateを取得し、Stateの値に応じてコンポーネントを出し分ける』という基本方針のもと、以下の3つの実装パターンについて紹介します。

  • 実行中と失敗はローディング状態にする
  • 実行中はローディング状態、失敗は失敗画面を表示する
  • 実行中はローディング状態、実行完了(失敗もしくは成功)で画面を表示する

以下では各パターンの実装を紹介します。

なお、ローディング中に表示するアイコンはsvg-spinners/svg-css/180-ring.svgを利用しています。

パターン1: 実行中と失敗はローディング状態にする

Selector経由で非同期処理の実行状況に関するStateを取得します。
そして、成功の場合はToDo一覧を表示、そうでない場合(つまり実行中と失敗の場合)はローディング状態を表示するようにします。

selector.ts

import { createSelector } from "@reduxjs/toolkit";
import { AppState } from "../store";
import { RequestState } from "../types/state/requests";

const selectRequests = (state: AppState): RequestState => state.requests;

const selectRequest = (actionType: string) =>
  createSelector([selectRequests], (requests) => requests[actionType] || {});

// 非同期処理の実行状況を取得するSelector
export const selectRequestStatus = (actionType: string) =>
  createSelector([selectRequest(actionType)], (request) => request?.status);

TodoListContainer.tsx

import { FC, useEffect } from "react";
import { TodoEntity } from "../types/state/todos";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { getTodos } from "../reducers/todosSlice";
import { useSelector } from "react-redux";
import { useAppDispatch } from "../lib/hooks/useAppDispatch";
import { TodoList } from "./TodoList";
import { selectRequestStatus } from "../selectors/requests";
import { Loading } from "./shared/Loading";
import { RequestStatus } from "../types/constants/requestStatusType";

export const TodoListContainer: FC = () => {
  const dispatch = useAppDispatch();
  const todos: TodoEntity[] = useSelector(selectTodosByVisibilityFilter);
  const requestStatus = useSelector(selectRequestStatus(getTodos.typePrefix));

  useEffect(() => {
    dispatch(getTodos());
  }, [dispatch]);

  return requestStatus === RequestStatus.Success ? (
    // ToDo一覧を表示するコンポーネント
    <TodoList todos={todos} />
  ) : (
    // ローディングアイコンを表示するコンポーネント
    <Loading />
  );
};

パターン2: 実行中はローディング状態、失敗は失敗画面を表示する

先ほど紹介したパターン1に失敗のケースを追加することで当該パターンを実装できます。

TodoListContainer.tsx

+ import { Failure } from "./shared/Failure";
(略)

export const TodoListContainer: FC = () => {
  const dispatch = useAppDispatch();
  const todos: TodoEntity[] = useSelector(selectTodosByVisibilityFilter);
  const requestStatus = useSelector(selectRequestStatus(getTodos.typePrefix));

  useEffect(() => {
    dispatch(getTodos());
  }, [dispatch]);

   // 失敗画面を表示するコンポーネント
+  if (requestStatus === RequestStatus.Failure) return <Failure />;

  return requestStatus === RequestStatus.Success ? (
    <TodoList todos={todos} />
  ) : (
    <Loading />
  );
};

パターン3: 実行中はローディング状態、実行完了(失敗もしくは成功)で画面を表示する

パターン1・パターン2のようにコンポーネント上で非同期処理の実行状況の判定をしてもよいですが、実行完了の判定をSelectorに実装することでコンポーネントのロジックが簡潔になります。

selector.ts

import { createSelector } from "@reduxjs/toolkit";
import { AppState } from "../store";
import { RequestStatus } from "../types/constants/requestStatusType";
import { RequestState } from "../types/state/requests";

const selectRequests = (state: AppState): RequestState => state.requests;

const selectRequest = (actionType: string) =>
  createSelector([selectRequests], (requests) => requests[actionType] || {});

// 実行完了(成功もしくは失敗)の場合trueを返すSelector
export const selectHasRequestDone = (actionType: string) =>
  createSelector([selectRequest(actionType)], (request) => {
    const complatedRequestStatuses = [
      RequestStatus.Failure,
      RequestStatus.Success,
    ];
    return complatedRequestStatuses.some((status) =>
      [request.status].includes(status)
    );
  });

TodoListContainer.tsx

import { FC, useEffect } from "react";
import { TodoEntity } from "../types/state/todos";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { getTodos } from "../reducers/todosSlice";
import { useSelector } from "react-redux";
import { useAppDispatch } from "../lib/hooks/useAppDispatch";
import { TodoList } from "./TodoList";
import { selectHasRequestDone } from "../selectors/requests";
import { Loading } from "./shared/Loading";

export const TodoListContainer: FC = () => {
  const dispatch = useAppDispatch();
  const todos: TodoEntity[] = useSelector(selectTodosByVisibilityFilter);
  const hasRequestDone = useSelector(
    selectHasRequestDone(getTodos.typePrefix)
  );
  useEffect(() => {
    dispatch(getTodos());
  }, [dispatch]);

  // 実行が完了するとhasRequestDoneがtrueになりTodo一覧が表示される
  return hasRequestDone ? <TodoList todos={todos} /> : <Loading />;
};

さいごに

今回の実装が組み込まれたサンプルコードはnishina555/next-thunk-todoappで公開しています。

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!