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

JavaScript

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

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

reduxjs/toolkitは1.5.1、react-reduxは7.2.4、redux-observableは1.2.0、rxjsは6.5.3、reactは17.0.2、typescriptは4.1.6を利用しています。

今回利用する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-observable-todoapp --template redux-typescript
$ cd redux-observable-todoapp

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

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

### srcディレクトリのファイルを削除
$ 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/react-api-todoapp-moack-boilerplate-tsこちらのコミットでも公開をしています。

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-observableのことはいったん置いておいて、まずは同期処理でTodoリストを画面に表示する実装をします。

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

src/redux/actions.ts

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

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

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

export type TodoActions = SetTodosAction;

src/redux/actionTypes.ts

export enum TodoActionTypes {
  SET_TODOS = "SET_TODOS",
}

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

src/redux/reducers/todos.ts

import { TodoActionTypes } 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 TodoActionTypes.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-observableなし)

redux-observableの導入の前に、まずはredux-observableなしで非同期処理を実装した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 getTodosRequest = async () => {
      const response = await axios
        .get(`http://localhost:4000/todos`)
        .catch((error) => {
          throw new Error(error.message);
        });
      setTodos(response.data);
    };
    getTodosRequest();
  }, []);
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: TodoItem) => {
            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-observableあり)

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

redux-observableのおさらい

初学者のためのredux-observable・Epic入門でも紹介しましたが、あらためてredux-observableの概要についておさらいします。

Observableについて

ObaservableはRxJSにおけるストリームを表現するクラスです。
RxJSではObservableを操作することでリアクティブプログラミングを実現します。

リアクティブプログラミングとは時間経過によって変化するデータを観測し、変更が生じた際にあらかじめ宣言した操作するというプログラミングのパラダイムです。
ストリームとは時間の経過によって変化するデータのことを指します。また、ストリームに存在する値のことをメッセージと呼びます。

Observableに関係する用語や概念の詳細解説は【RxJS入門】Observable、Observer、subscribe、Operatorの概要・関係性で紹介しています。

redux-observableについて

redux-observableはReduxでObservableを扱えるようにするミドルウェアです。

Epicについて

redux-observableではEpicと呼ばれる概念が登場します。
EpicはActionを受け取り、新たなActionを返す関数のことをいいます。公式ドキュメントでは、EpicはActionを受け取り新たなActionを返すため『アクションイン・アクションアウト』と表現されています。
Epicはアクションを常に監視し、Actionが実行(Dispatch)されたタイミングで処理を行います。

Epic内でActionはObservable(ストリーム)のデータ(メッセージ)として扱われます。つまり、Epicを型で表現すると以下のようになります。1
なお、Epicにおけるドルマーク($)はObservable変数を識別するためのRxJSの一般的な慣習です。

// Epicはアクションのストリームを受け取り、新たなアクションのストリームを返す
function (action$: Observable<Action>, state$: StateObservable<State>): Observable<Action>;

Epicから出力されたActionは即座に実行されます。コードで表現すると以下のようになります。1

// epic(action$, state$)の結果はObservable
// subscribeはObservableのメッセージ(ここでいうアクション)を取得するObservableのメソッド
// subcribeの第一引数はObservableのメッセージを処理する関数(Observer.next)

epic(action$, state$).subscribe(store.dispatch)

ReduxではActionの発行とReducerの実行は同期されているため、ActionとReducerの間には非同期処理を実装できません。
しかし、Epicを利用することで『Action in → 非同期処理 → Action out → Reducer』という処理の流れを実現できるため、ActionとReducerの間に非同期処理を実装できます。
`

redux-observableのインストール

$ yarn add redux-observable@1.2.0
$ yarn add rxjs@6.5.3
2021年8月現在、最新のredux-observable 2.0.0は手元のTypeScript環境(typescript 4.1.6)でうまく動作しなかったため旧バージョンをインストールしています。

Epicを実行するトリガーとなるアクションを追加

今回はGET_TODOS_REQUESTというAction Typeの発行をトリガーに、APIへのリクエストとsetTodosアクションを行うEpicを作成します。

まずはGET_TODOS_REQUESTを発行するActionを実装します。

src/redux/actionTypes.ts

export enum GetTodosType {
  GET_TODOS_REQUEST = "GET_TODOS_REQUEST",
}

src/redux/actions.ts

import { GetTodosType} from "./actionTypes";

type GetTodosRequestAction = {
  type: GetTodosType.GET_TODOS_REQUEST;
};
export const getTodosRequest = (): GetTodosRequestAction => ({
  type: GetTodosType.GET_TODOS_REQUEST,
});

export type GetTodosActions = GetTodosRequestAction;

Epicの作成

今回作成するEpicの処理のフローは以下の通りです。

  1. 『GET_TODOS_REQUEST』というAction Typeを検知する
  2. APIへ非同期通信をしてTodoリストを取得
  3. APIの結果をsetTodosを利用してStateにセット

上記の要件を満たすEpicsの実装は以下のようになります。

src/redux/epics/todoEpics.ts

import { combineEpics, Epic, ofType } from "redux-observable";
import { map, mergeMap } from "rxjs/operators";
import { GetTodosType } from "../actionTypes";
import {
  TodoActions,
  setTodos,
  GetTodosActions,
} from "../actions";
import axios from "axios";
import { from } from "rxjs";

export const getTodosEpic: Epic<GetTodosActions | TodoActions> = (action$) =>
  action$.pipe(
    ofType(GetTodosType.GET_TODOS_REQUEST), // GET_TODOS_REQUESTというAction Typeが発行されたら以下を実行
    mergeMap(() => // メッセージを受け取り、新たなストリーム(Observable)を作成する
      from(axios.get("http://localhost:4000/todos")) // PromiseオブジェクトからObservableを生成
        .pipe(
          map((response) => setTodos(response.data)) // APIの結果をsetTodosを利用してStateにセットする
        )
    )
  );

Epicの型定義はinterface Epic<Input extends Action<any> = any, Output extends Input = Input, State = any, Dependencies = any>です。

つまり、上記のコードに記載されているEpic<GetTodosActions | TodoActions>は『入力されるアクション(Action in)および出力されるアクション(Action out)の型は、GetTodosActionsもしくはTodoActions』ということを表現しています。

また、mergeMapは『メッセージから新たなストリームを作成するOperator』です。
今回の場合ですと、アクションストリーム(action$)からGET_TODOS_REQUESTというメッセージをmergeMapで受け取り、fromで新たなObservableを生成しています。

mapmergeMapの違いの詳細解説は【RxJS】mapとmergeMapの違いで紹介しています。

Epicの結合

EpicはcombineEpics()を利用することで結合できます。

まだgetTodosEpicというEpicしか定義していないためEpicの結合は不要ですが、一般的な手順に従いEpicの結合をします。

src/redux/epics/index.ts

import { combineEpics } from "redux-observable";
import { getTodosEpic } from "./todoEpics";

export default combineEpics(getTodosEpic);

Storeにredux-observableをつなぎこむ

EpicをReduxで利用できるようにするためStoreを修正します。修正項目は以下の通りです。

  • ミドルウェアのインスタンスを作成
  • 作成したインスタンスをStoreに組み込む
  • インスタンスにEpicを渡して起動させる

コードは以下の通りです。2 3

なお今回はStoreの作成にはRedux Toolkitで提供されているconfigureStore()を利用しています。

src/redux/store.ts

import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";
import rootEpic from "./epics";
import { createEpicMiddleware } from "redux-observable";

const epicMiddleware = createEpicMiddleware(); // ミドルウェアのインスタンスを作成
const store = configureStore({
  reducer: rootReducer,
  middleware: [epicMiddleware], // Storeにミドルウェアを組み込む
});

epicMiddleware.run(rootEpic); // インスタンスにEpicを渡して起動させる

export default store;

Epicを実行するトリガーとなるアクションをコンポーネントから呼び出す

getTodosRequest()をコンポーネントから呼び出すことでEpicに実装したロジックが実行されます。

src/components/TodoList.tsx

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

type TodoListProps = {
  todos: Array<TodoItem>;
  getTodosRequest: () => GetTodosActions;
};

const TodoList: React.FC<TodoListProps> = ({ todos, getTodosRequest }) => {
  useEffect(() => {
    getTodosRequest(); // GET_TODOS_REQUESTが発行されるのでEpicのロジックが実行される
  }, []);
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: TodoItem) => {
            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, { getTodosRequest })(TodoList);

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

まとめ

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

redux-observableを利用した非同期処理まとめ
  • redux-observableではEpicに非同期処理を実装する
  • Epicを利用した非同期処理の流れは「アクション → 非同期処理 → アクション」
  • ReduxでEpicを利用するためには、Storeにredux-observableを組み込む必要がある
  • Epicのトリーガーとなるアクションをコンポーネントで呼び出すことで非同期処理がEpicで実行される

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

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