Todoアプリで理解するRedux Thunkによる非同期処理の実装方法

JavaScript

Reduxで非同期処理を実装する方法は主にRedux Thunk、Redux Saga、Redux Observableがあります。

Redux ThunkはRedux Toolkitにデフォルトでインストールされているミドルウェアです。そのため、手軽にReduxで非同期処理を実装したい場合はRedux Thunkを利用するケースが多いでしょう。

今回はAPIを利用してTodoリストを取得するTodoアプリを例に、Redux Thunkによる非同期処理の実装方法について紹介をします。

reduxjs/toolkitは1.5.0、react-reduxは7.1.16、redux-thunkは2.3.0、reactは17.0.2、typescriptは3.8.3を利用しています。

今回利用するAPIサーバについて

APIサーバはJSON Serverを利用することで簡単に作成できます。
今回作成するTodoアプリは、4000番ポートにアクセスするとTodoリストが取得できる前提で実装をします。

実際にTodoアプリの挙動を確認する際は以下の手順でAPIサーバを用意してください。

todos.json


{ "todos": [ { "id": 1, "content": "do something", "completed": false }, { "id": 2, "content": "go somewhere", "completed": false } ] }
### json-serverのインストール
$ yarn add json-server

### port4000でJSON Serverを起動し、todos.jsonが取得できるようにする
$ json-server todos.json --port 4000

モックを作成する

create-react-appを利用してアプリケーションのひな型を作成します。

### create-react-appを利用してアプリケーションを作成
$ npx create-react-app redux-thunk-todoapp --template redux-typescript
$ cd redux-thunk-todoapp

### アプリケーションで必要になるnpmのインストール
$ yarn add classnames @types/classnames

### 今回利用するHTTPクライアントであるaxiosをインストール
$ yarn add axios

### srcディレクトリのファイルを削除
$ cd redux-thunk-todoapp
$ rm -rf src/*

モック完成時のディレクトリ構成は以下のようになります。

./src
├── TodoApp.tsx
├── components
│   ├── Todo.tsx
│   └── TodoList.tsx
├── index.tsx
├── redux
│   ├── reducers
│   │   ├── index.ts
│   │   └── todos.ts
│   ├── store.ts
│   └── types.ts
└── styles.css

モックのソースコードはnishina555/redux-thunk-todoappこちらのコミットでも公開をしています。

Component

モックのUIとなるコンポーネントを作成します。

src/TodoApp.tsx

import React from "react";
import TodoList from "./components/TodoList";
import "./styles.css";

export default function TodoApp() {
  return (
    <div className="todo-app">
      <h1>Todo List</h1>
      <TodoList />
    </div>
  );
}

src/components/TodoList.tsx

import React from "react";
import Todo from "./Todo";

const TodoList = () => {
  const todos: any = {};
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: any, index: any) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

export default TodoList;

src/components/Todo.tsx

import React from "react";
import cx from "classnames";
import { TodoItem } from "../redux/types";

type TodoProps = {
  todo: TodoItem;
};

const Todo: React.FC<TodoProps> = ({ todo }) => (
  <li
    className="todo-item"
    onClick={() => {} /** dispatches action to toggle todo */}
  >
    {todo && todo.completed ? "👌" : "👋"}{" "}
    <span
      className={cx(
        "todo-item__text",
        todo && todo.completed && "todo-item__text--completed"
      )}
    >
      {todo.content}
    </span>
  </li>
);

export default Todo;

Reducer

Todoに関するReducerのひな型を作成します。
この段階ではActionは作成せず、Reducerは現在のStateをそのままreturnするだけの実装となっています。

src/redux/reducers/index.ts

import { combineReducers } from "redux";
import todos from "./todos";

export default combineReducers({ todos });

src/redux/reducers/todos.ts

import { TodoState } from "../types";

const initialState: TodoState = {
  todoItems: [],
};

const todos = (state = initialState, action: any): any => {
  return state;
};

export default todos;

Store

作成したReducerをStoreに読み込ませます。
今回はRedux ToolkitのconfigureStoreを利用してStoreを作成しました。

src/redux/store.ts

import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";

const store = configureStore({
  reducer: rootReducer,
});

export default store;

Provider

Storeのつなぎこみをします。

src/index.tsx

import React from "react";
import ReactDOM from "react-dom";

import TodoApp from "./TodoApp";
import { Provider } from "react-redux";
import store from "./redux/store";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Provider store={store}>
    <TodoApp />
  </Provider>,
  rootElement
);

そのほか

必要になるtypeを定義します。

src/redux/types.ts

export type TodoItem = {
  id: number;
  content: string;
  completed: boolean;
}

export type TodoState = {
  todoItems: Array<TodoItem>;
}

export type RootState = {
  todos: TodoState;
}

CSSは以下の通りです。

src/styles.css

.todo-app {
  font-family: sans-serif;
}

/** add todo **/
.add-todo {
  margin-left: 0.5rem;
}

/** todo list **/
.todo-list {
  margin-top: 1rem;
  text-align: left;
  list-style: none;
}

/** todo item **/
.todo-item {
  font-family: monospace;
  cursor: pointer;
  line-height: 1.5;
}
.todo-item__text--completed {
  text-decoration: line-through;
  color: lightgray;
}

/** visibility filters **/
.filter {
  padding: 0.3rem 0;
  margin: 0 0.3rem;
  cursor: pointer;
}
.filter--active {
  border-bottom: 1px solid black;
}

動作確認

yarn startで起動し、以下のような画面が表示されればOKです。

同期処理でTodoを表示させる

非同期処理やRedux Thunkのことはいったん置いておいて、まずは同期処理でTodoリストを画面に表示する実装をします。

読み込んだTodoリストをStateにセットするActionを作成します。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";
import { TodoItem } from "./types";

type SetTodosAction = {
  type: ActionTypes.SET_TODOS;
  payload: { todos: TodoItem[] };
};

export const setTodos = (todos: TodoItem[]): SetTodosAction => ({
  type: ActionTypes.SET_TODOS,
  payload: { todos },
});

export type TodoActions = SetTodosAction;

src/redux/actionTypes.ts

export enum ActionTypes {
  SET_TODOS = "SET_TODOS",
}

作成したActionに対応するReducerを実装します。

src/redux/reducers/todos.ts

import { ActionTypes } from "../actionTypes";
import { TodoState } from "../types";
import { TodoActions } from "../actions";

const initialState: TodoState = {
  todoItems: [],
};

const todos = (state = initialState, action: TodoActions): TodoState => {
  switch (action.type) {
    case ActionTypes.SET_TODOS: {
      const { todos } = action.payload;
      return {
        todoItems: todos,
      };
    }
    default:
      return state;
  }
};

export default todos;

TodoListコンポーネントがマウントされたタイミングでTodoリストを取得・表示するようにします。
マウント時に実行されるuseEffect()内でTodoリストの読み込み、さきほど作成したActionを実行(dispatch)します。

src/components/TodoList.tsx

import React, { useEffect } from "react";
import Todo from "./Todo";
import { setTodos, TodoActions } from "../redux/actions";
import { RootState } from "../redux/types";
import { connect } from "react-redux";
import { TodoItem } from "../redux/types";

type TodoListProps = {
  todos: Array<TodoItem>;
  setTodos: (todos: Array<TodoItem>) => TodoActions;
};

const TodoList: React.FC<TodoListProps> = ({ todos, setTodos }) => {
  useEffect(() => {
    const data: Array<TodoItem> = [
      { id: 1, content: "do something", completed: false },
      { id: 2, content: "go somewhere", completed: false },
    ];
    setTodos(data);
  }, []); // 第2引数を空配列にすることでマウント・アンマウント時のみ実行される
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: any, index: any) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

const mapStateToProps = (state: RootState) => {
  const todos = state.todos.todoItems;
  return { todos };
};
export default connect(mapStateToProps, { setTodos })(TodoList);

APIサーバとTodoアプリを起動し、以下のような画面が表示されればOKです。

非同期処理でTodoを表示させる(Redux Thunkなし)

Redux Thunkの導入の前に、まずはRedux Thunkなしで非同期処理を実装したTodoアプリの紹介をします。

マウント時に実行されるuseEffect()内でAPIにアクセスをし、レスポンスデータをsetTodosアクションを利用してStateにセットします。

src/components/TodoList.tsx

import React, { useEffect } from "react";
import Todo from "./Todo";
import { setTodos, TodoActions } from "../redux/actions";
import { RootState } from "../redux/types";
import { connect } from "react-redux";
import { TodoItem } from "../redux/types";
import axios from "axios";

type TodoListProps = {
  todos: Array<TodoItem>;
  setTodos: (todos: Array<TodoItem>) => TodoActions;
};

const TodoList: React.FC<TodoListProps> = ({ todos, setTodos }) => {
  useEffect(() => {
    const fetchTodos = async () => {
      const response = await axios
        .get(`http://localhost:4000/todos`)
        .catch((error) => {
          throw new Error(error.message);
        });
      setTodos(response.data);
    };
    fetchTodos();
  }, []);
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: TodoItem, index: number) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

const mapStateToProps = (state: RootState) => {
  const todos = state.todos.todoItems;
  return { todos };
};
export default connect(mapStateToProps, { setTodos })(TodoList);

async/awaitの概要やエラーハンドリング方法についてはJavaScriptの非同期処理の定番!async/await概要・挙動まとめで解説をしています。

JavaScriptの非同期処理の定番!async/await概要・挙動まとめ

非同期処理でTodoを表示させる(Redux Thunkあり)

Redux Thunkを利用した非同期処理の方法について紹介します。

そもそもThunkとは?Redux Thunkについて

先ほど紹介したRedux Thunkを利用しない非同期処理の実装は『コンポーネントで非同期処理 → Actionのdispatch』という手順をふみました。
非同期処理がコンポーネントに実装されているとコンポーネントのロジックが複雑になるので、Actionで非同期処理を行いたいと考えたとします。

しかしReduxでは、dispatchするActionはtypeとpalyoadを持つオブジェクトでなければいけません。
そのため、async関数(非同期処理)でActionCreatorを実装した場合、ActionオブジェクトではなくPromiseオブジェクトが返されてしまうため、Actionに非同期処理を実装できません。

そこで登場するのがRedux Thunkです。

Thunkとは『関数を使用して式の評価や計算を遅らせるプログラミングの概念』です。
たとえば、以下のようなものがThunk関数です。

let thunk = () => 1 + 2
// この時点では 1 + 2 は評価されない

thunk()
// 3
// Thunk関数が呼び出されたタイミングで 1 + 2 が評価される

Redux ThunkはThunkの概念をReduxで利用できるようにするミドルウェアです。
Redux Thunkを導入することで、dispatch()にActionオブジェクトだけでなく、非同期処理が実装されている関数も渡すことができます。
Actionがdispatchされた場合は通常通りReducerが実行されます。関数がdispatchされた場合はRedux Thunkで処理が行われ、Redux ThunkからActionのdispatchが実行されます。

Reduxの非同期処理のデータフローはRedux『Redux Async Data Flow』で紹介されている図がわかりやすいです。

Redux Thunkのインストール

Redux Thunkを有効にするためStoreを編集します。

Redux ToolkitのAPI(configureStore)の場合、デフォルトでRedux ThunkとRedux DevTools Extensionが有効になっているため、ここで紹介するRedux Thunkのインストール作業は不要です。

以下はconfigureStoreを利用している場合の手順となります。

redux-thunkをインストールし、applyMiddlewareを読み込むことでRedux Thunkが利用できます。1

$ yarn add redux-thunk

src/redux/store.ts

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

const store = createStore(
  rootReducer,
+ applyMiddleware(thunk)
);

export default store

Redux Thunkに加え、Redux DevTools Extensionも利用する場合はredux-devtools-extensioncomposeWithDevToolsを利用して以下のようにします。2

src/redux/store.ts

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';
import { composeWithDevTools } from "redux-devtools-extension";

const store = createStore(
  rootReducer,
+ composeWithDevTools(applyMiddleware(thunk))
);

export default store

ThunkActionを返すActionCreatorを実装する

ThunkActionは『DispatchとStateを引数にもつ関数』です。
つまりThunkActionを返すActionCreatorとは、関数を返すActionCreatorのことを指します。
ThunkAction内に非同期処理を実装することで、Reduxでも非同期処理が扱えます。
ActionのdispatchはThunkAction内で行います。

ThunkActionの型定義はThunkAction<R, S, E, A>となっており、それぞれの意味は以下の通りです。

ThunkAction<R, S, E, A>の意味
  • R(第一引数): Actionの戻り値の型
  • S(第二引数): Stateの型
  • E(第三引数): ThunkActionのextraArgument(dispatch・getState以外の引数)の型
  • A(第四引数): Actionの型

『APIから非同期で取得したデータをStateに保存する』という処理(fetchTodos())はThunkActionを利用すると以下のようにかけます。
fetchTodos()はThunkActionという関数を返し、ThunkAction内で非同期処理が行われます。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";
import { TodoItem, RootState } from "./types";
import { Dispatch } from "redux";
import axios from "axios";
import { ThunkAction } from "redux-thunk";

type SetTodosAction = {
  type: ActionTypes.SET_TODOS;
  payload: { todos: TodoItem[] };
};

export const setTodos = (todos: TodoItem[]): SetTodosAction => ({
  type: ActionTypes.SET_TODOS,
  payload: { todos },
});

export const fetchTodos = (): ThunkAction<
  void,
  RootState,
  unknown,
  TodoActions
> => {
  return async (dispatch: Dispatch<TodoActions>) => {
    const response = await axios
      .get(`http://localhost:4000/todos`)
      .catch((error) => {
        throw new Error(error.message);
      });
    dispatch(setTodos(response.data));
  };
};

export type TodoActions = SetTodosAction;

{ return (...) }の部分は省略可能ですので、以下のようにアロー関数を2つ並べた形で記述もできます。

src/redux/actions.ts

export const fetchTodos = (): ThunkAction<
  void,
  RootState,
  unknown,
  TodoActions
> => async (dispatch: Dispatch<TodoActions>) => {
  const response = await axios
    .get(`http://localhost:4000/todos`)
    .catch((error) => {
      throw new Error(error.message);
    });
  dispatch(setTodos(response.data));
};

ThunkActionの型定義を明記せず、以下のようにシンプルな形で記述もできます。

src/redux/actions.ts

export const fetchTodos = () => async (dispatch: Dispatch<TodoActions>) => {
  const response = await axios
    .get(`http://localhost:4000/todos`)
    .catch((error) => {
      throw new Error(error.message);
    });
  dispatch(setTodos(response.data));
};

もしgetState()を利用する場合はたとえば以下のように第二引数で指定します。

// getStateを利用するThunkAction部分のサンプル
async (dispatch , getState) => {
  const { todos } = getState(); // 現在のStateのtodosが取得できる
  dispatch(addTodo(todo.content, todos.todoItems.length));

}

ThunkActionをコンポーネントから呼び出す

さきほど作成したfetchTodos()の呼び出しは以下のようになります。

src/components/TodoList.tsx

import React, { useEffect } from "react";
import Todo from "./Todo";
import { fetchTodos } from "../redux/actions";
import { RootState } from "../redux/types";
import { connect } from "react-redux";
import { TodoItem } from "../redux/types";

type TodoListProps = {
  todos: Array<TodoItem>;
  fetchTodos: () => void;
};

const TodoList: React.FC<TodoListProps> = ({ todos, fetchTodos }) => {
  useEffect(() => {
    fetchTodos();
  }, []);
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: TodoItem, index: number) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

const mapStateToProps = (state: RootState) => {
  const todos = state.todos.todoItems;
  return { todos };
};
export default connect(mapStateToProps, { fetchTodos })(TodoList);

Todoアプリを起動し、Todoの一覧が確認できたらOKです。

参考: APIアクセスに関する専用クラスを作成する

Redux Thunkを利用した場合、Actionのソースコード内でawait const response axios.get(...)と記述をすることで非同期通信ができます。

上記の方法でも問題はないのですが、APIアクセスに関するロジックは専用クラスに外だしすることでActionの実装が簡潔になります。

リファクタリングの一例を紹介します。

src/api/index

import axios from "axios";

const baseURL = `http://localhost:4000/`;
const axiosInstance = axios.create({
  baseURL: baseURL,
});

export default axiosInstance;

src/api/todos.ts

import axiosInstance from "./index";

class Todos {
  static async getAll() {
    const response = await axiosInstance.get(`todos`).catch((error) => {
      throw new Error(error.message);
    });
    return response.data;
  }
}

export default Todos;

上記のクラスを利用した場合、APIアクセスは以下のようになります。

// Before: 非同期通信をActionで実装する場合
const response = await axios
  .get(`http://localhost:4000/todos`)
  .catch((error) => {
    throw new Error(error.message);
  });
dispatch(setTodos(response.data));

// After: APIアクセスのクラスを利用する場合
const todos = await TodosApiService.getAll();
dispatch(setTodos(todos));

まとめ

以上でRedux Thunkを利用した非同期処理の実装方法の紹介を終わります。

今回のまとめ
  • Thunkとは『関数を使用して式の評価や計算を遅らせるプログラミングの概念』のこと
  • Redux Thunkを導入することでReduxで非同期処理を実装できる
  • ThunkActionを返すActionCreatorを実装し、ThunkAction内で非同期処理を実装する

本記事ではTodoリストを表示させる部分までしか実装していませんが、『Todoリストのフィルタリング』と『Todoのステータス変更』も実装したソースコードをnishina555/redux-thunk-todoappに公開しました。以下のような画面のTodoアプリとなっています。

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