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概要・挙動まとめで解説をしています。
非同期処理で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
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の処理のフローは以下の通りです。
- 『GET_TODOS_REQUEST』というAction Typeを検知する
- APIへ非同期通信をしてTodoリストを取得
- 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を生成しています。
map
とmergeMap
の違いの詳細解説は【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を渡して起動させる
なお今回は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ではEpicに非同期処理を実装する
- Epicを利用した非同期処理の流れは「アクション → 非同期処理 → アクション」
- ReduxでEpicを利用するためには、Storeにredux-observableを組み込む必要がある
- Epicのトリーガーとなるアクションをコンポーネントで呼び出すことで非同期処理がEpicで実行される
本記事ではTodoリストを表示させる部分までしか実装していませんが、『Todoリストのフィルタリング』と『Todoのステータス変更』も実装したソースコードをnishina555/redux-observable-todoappに公開しました。以下のような画面のTodoアプリとなっています。
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!