本記事の目的
こんにちは!@flat-fieldです!
普段はアルバイトでバックエンドの実装を行なっておりますが,フロントエンドも少しは触れるようになりたいという思いから,react, redux, redux-sagaの勉強をしていました.
最初は「難しいな〜〜」と思うことばかりでしたが,3週間ほど勉強する中で少しずつ理解できることも増えたので,これらの技術を用いて日報アプリを作ってみました.
本記事では,redux, redux-sagaの仕組みについて,自作した日報アプリを元に説明できればと思います.
なお,細かい実装の部分には触れていません.なんとなく「redux, redux-sagaはこんな感じなんだなあ」と分かっていただければ幸いです(実装について詳しく理解したい方は,おすすめの書籍を最後に紹介しているので,そちらを参照してみてください).
なお勉強して間もないので,何か間違っていることや,補足事項がありましたら,コメントで教えていただけると有り難いです.
よろしくお願いいたします.
reduxとは?
Reactでは,コンポーネントを組み合わせてアプリケーションを構成します.
コンポーネントには「状態(state)を持つコンポーネント」と「状態(state)を持たないコンポーネント」があります.
実際のアプリケーションでは,複数のコンポーネント間で共有したいstateが存在することがよくあります.
例えば,ユーザのログイン情報を複数のコンポーネントで利用したいときなどが挙げられます.
では,あるstateを複数のコンポーネントで使用するにはどうすればいいでしょうか?
安直な方法としては,最上位コンポーネントに全てのstateを集約し,それを子コンポーネントにバケツリレーのごとくstateを引き継いであげる,ということが考えられます.でも,流石にそんなことしたくないですよね...
そこで,reduxが登場します.reduxは以下のような3つの原則を採用しており,シンプルかつ簡潔にあるstateを複数のコンポーネントで使用することができるようになります.
- Single source of truth
- State is read-only
- Changes are made with pure functions
"Single source of truth"は,アプリケーションの全てのstateを"store"と呼ばれるオブジェクトで一元管理することを意味します.
複数のstoreで管理すると,store間のデータのやりとりが複雑になり,バグの温床となります.1つのstoreで管理することによって,テストやデバッグをやりやすくすることができます.
"State is read-only"は,stateを直接書き換えることができないことを意味します.もしstateを変更したい場合は,必ず"action"を発行する必要があります.この"action"を必ず発行しないとstateの状態を変更することができない仕組みによって,予期せぬ場所からstateの書き換えができないようにしています.
"Changes are made with pure functions"は,stateの変更は"Reducer"と呼ばれる純粋関数(引数が同じならば,必ず同じ結果が返される関数)によって行われます.つまりReducerは,stateとactionの2つを引数とし,新たなstateを返す,ということを実行します.
# 言うなればReducerはこんな感じ
(prevState, action) => newState
reduxの全体の概念図は以下の通りです.
図を見てもらうとわかるのですが,各ノードの依存関係が単方向ですね.これによりアプリケーションの構造が複雑になるのを防いでいます.
redux-sagaとは?
redux-sagaとは,一言で言うと**「reduxで非同期処理を扱うための仕組み」**のことです.
全体の概念図を見てみましょう.
ややこしくなったように見えますが,実は右側2/3はreduxと同じ構造になっています.reduxにsagaという,actionを監視して非同期処理を実現する機能を追加したものがredux-sagaです.
このように,特定のactionをWatch Taskが監視し,Taskが実際の非同期処理を実現することにより,シンプルな構造のまま非同期処理を実現することができます.
ちなみに余談ですが,reduxで非同期処理を実現するもう一つの手法として「redux-thunk」というものがあります.こちらはredux-sagaに比べて学習コストが小さくて済むというメリットがありますが,依存関係が複雑になり,react本来のコンポーネントの振る舞いができなくなるというデメリットがあります.
日報アプリを作ってみた!
redux, redux-sagaについて説明したところで,実際に日報アプリを作ってみました.
この日報アプリでは,以下のことができます.
- その日の振り返り,明日の目標を毎日記録することができる
- 当日の日報のみ修正することができる.
- 過去の日報を閲覧することができる.
Githubはこちらから参照してください.
(なお,APIについても自作しており,こちらから参照できます.)
redux-sagaを用いた開発の流れ
全体の概念図の流れ通りに実装すると良さそうです.なので以下のような流れで実装しました.
(1) まずは非stateコンポーネント(Viewの役割をメインに担う)を作成.
(2) Actionを実装する.
(3) Reducerを実装する.
(4) 非同期処理通信を実装する.
(5) (4)の非同期処理を利用するSagaのタスクを作成する.
(6) 最上位のコンポーネントに,全コンポーネントでsagaを利用できるような仕組みを実装する.
(7) 最後に,state有コンポーネント((1)をimportして,機能を追加するコンポーネント)を作成する.
(1) まずは非stateコンポーネント(Viewの役割をメインに担う)を作成.
序盤でもお伝えしましたが,コンポーネントには2種類有ります.ここでは,非stateコンポーネントを先に実装し,Viewを整えます.
src/components/*.tsx
がこれに該当します.
一例として,日報を記入するためのコンポーネントsrc/components/WhiteDailyPart.tsx
をみてみましょう.
import React, { FC } from 'react';
import { Grid, Form, Button } from 'semantic-ui-react';
export interface WriteDailyProps {
postDailyTmp?: () => void;
}
const WriteDailyPart: FC<WriteDailyProps> = ({
postDailyTmp = () => undefined,
}) => (
<Grid.Column width={10}>
<Form>
<Form.Field>
<Form.TextArea
id="today"
label="今日の振り返り"
rows={20}
placeholder="ここに本日の振り返りを記入してください.フォーマットは規定のものにしたがってください.そして毎日継続しまししょう."
/>
</Form.Field>
<Form.Field>
<Form.TextArea
id="tomorrow"
label="明日の目標"
rows={10}
placeholder="明日の目標を具体的に記述しましょう."
/>
</Form.Field>
<Form.Field>
<Form.Input
fluid
id="point"
label="本日の点数(100点満点中)"
placeholder="本日の点数(100点満点中)"
/>
</Form.Field>
<Button type="submit" onClick={postDailyTmp}>
提出する
</Button>
</Form>
</Grid.Column>
);
export default WriteDailyPart;
(2) Actionを実装する.
続いてActionを実装します.src/actions/daily.ts
をみてみましょう.
流れとしては,
- Action Type(それぞれのActionに付与するもの)を定義
- Actionを発行するための関数を実装
- Return Typeをまとめて型定義
という感じです.
import { AxiosError } from 'axios';
import { Daily } from '../services/daily/model';
// 1. Action Typeを定義
export const REGISTER_DAILY_START = 'DAILY/REGISTER_DAILY_START';
export const REGISTER_DAILY_SUCCEED = 'DAILY/REGISTER_DAILY_SUCCEED';
export const REGISTER_DAILY_FAIL = 'DAILY/REGISTER_DAILY_FAIL';
export const GET_DAILIES_START = 'DAILY/GET_DAILIES_START';
export const GET_DAILIES_SUCCEED = 'DAILY/GET_DAILIES_SUCCEED';
export const GET_DAILIES_FAIL = 'DAILY/GET_DAILIES_FAIL';
// 2. Actionの発行
export const registerDaily = {
start: (params: Daily) => ({
type: REGISTER_DAILY_START as typeof REGISTER_DAILY_START,
payload: params,
}),
succeed: (params: Daily, result: Daily) => ({
type: REGISTER_DAILY_SUCCEED as typeof REGISTER_DAILY_SUCCEED,
payload: { params, result },
}),
fail: (params: Daily, error: AxiosError) => ({
type: REGISTER_DAILY_FAIL as typeof REGISTER_DAILY_FAIL,
payload: { params, error },
error: true,
}),
};
export const getDailies = {
start: () => ({
type: GET_DAILIES_START as typeof GET_DAILIES_START,
}),
succeed: (result: Daily[]) => ({
type: GET_DAILIES_SUCCEED as typeof GET_DAILIES_SUCCEED,
payload: { result },
}),
fail: (error: AxiosError) => ({
type: GET_DAILIES_FAIL as typeof GET_DAILIES_FAIL,
payload: { error },
}),
};
// 3. ReturnTypeをまとめて型定義.あとで使う.
export type DailyAction =
| ReturnType<typeof registerDaily.start>
| ReturnType<typeof registerDaily.succeed>
| ReturnType<typeof registerDaily.fail>;
export type GetDailiesAction =
| ReturnType<typeof getDailies.start>
| ReturnType<typeof getDailies.succeed>
| ReturnType<typeof getDailies.fail>;
(3) Reducerを実装する.
続いてReducerを実装します.前述の通り,Reducerはactionを受け取って,それぞれのactionごとに(prevState, action) => newState
を実行します.
import { Reducer } from 'redux';
import { Daily } from './services/daily/model';
import {
DailyAction,
GetDailiesAction,
REGISTER_DAILY_START,
REGISTER_DAILY_SUCCEED,
REGISTER_DAILY_FAIL,
GET_DAILIES_START,
GET_DAILIES_FAIL,
GET_DAILIES_SUCCEED,
} from './actions/daily';
export interface DailyState {
dailies: Daily[];
}
export const initialState: DailyState = { dailies: [] };
const counterReducer: Reducer<DailyState, DailyAction | GetDailiesAction> = (
state: DailyState = initialState,
action: DailyAction | GetDailiesAction,
): DailyState => {
switch (action.type) {
case REGISTER_DAILY_START:
return {
...state,
dailies: state.dailies,
};
case REGISTER_DAILY_SUCCEED:
return {
...state,
dailies: [action.payload.result, ...state.dailies],
};
case REGISTER_DAILY_FAIL:
return {
...state,
};
case GET_DAILIES_START:
return {
...state,
dailies: state.dailies,
};
case GET_DAILIES_SUCCEED:
return {
...state,
dailies: [...state.dailies, ...action.payload.result],
};
case GET_DAILIES_FAIL:
return {
...state,
};
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const _: never = action;
return state;
}
}
};
export default counterReducer;
(4) 非同期処理通信を実装する.
続いて非同期処理通信を実装します.
src/services/daily/api.ts
では,APIを叩くためのインタフェース(postDailyFactory, getDailiesFactory)を提供しています.
import axios from 'axios';
import { Daily } from './model';
interface ApiConfig {
baseURL: string;
timeout: number;
}
const DEFAULT_API_CONFIG: ApiConfig = {
baseURL: 'http://localhost:8080',
timeout: 7000,
};
// クロージャーの利用
// 実際の使い方:const getMembers = getMembersFactory({timeout: 3000});
// try {
// const users = await getMembers(‘facebook’);
// } catch(err) {
// console.log(err);
// }
export const postDailyFactory = (optionConfig?: ApiConfig) => {
const config = {
...DEFAULT_API_CONFIG,
...optionConfig,
};
const instance = axios.create(config);
const postDaily = async (daily: Daily) => {
const params = new URLSearchParams();
params.append('id', `${daily.id}`);
params.append('date', daily.date);
params.append('today', daily.today);
params.append('tomorrow', daily.tomorrow);
params.append('point', daily.point);
const response = await instance.post(`/daily/create`, params);
if (response.status !== 200) {
throw new Error('Server Error');
}
const responseDaily: Daily = {
id: response.data.daily.ID,
date: response.data.daily.date,
today: response.data.daily.today,
tomorrow: response.data.daily.tomorrow,
point: response.data.daily.point,
};
return responseDaily;
};
return postDaily;
};
export const getDailiesFactory = (optionConfig?: ApiConfig) => {
const config = {
...DEFAULT_API_CONFIG,
...optionConfig,
};
const instance = axios.create(config);
const dailies: Daily[] = [];
const getDailies = async () => {
const response = await instance.get(`/dailies`);
if (response.status !== 200) {
throw new Error('Server Error');
}
// console.log(response.data.dailies);
for (let i = 0; i < response.data.dailies.length; i += 1) {
const d: Daily = {
id: response.data.dailies[i].ID,
date: response.data.dailies[i].date,
today: response.data.dailies[i].today,
tomorrow: response.data.dailies[i].tomorrow,
point: response.data.dailies[i].point,
};
dailies.push(d);
}
// return JSON.stringify(response.data.dailies);
return dailies;
};
return getDailies;
};
(5) (4)の非同期処理を利用するSagaのタスクを作成する.
続いて,sagaのタスクを作成します(src/sagas/).この部分では,特定のactionを観測したら,(4)で作成したAPIインタフェースを利用してAPIを叩き,正しく結果が返ってきたらsucceedアクションを発行し,stateを変更します.もしエラーが返ってきたらfailアクションを発行します.
import { all, call, fork, put, takeLatest } from 'redux-saga/effects';
// import * as Action from '../actions/daily';
import {
registerDaily,
getDailies,
REGISTER_DAILY_START,
GET_DAILIES_START,
} from '../actions/daily';
import { postDailyFactory, getDailiesFactory } from '../services/daily/api';
function* runPostDaily(action: ReturnType<typeof registerDaily.start>) {
const daily = action.payload;
try {
const api = postDailyFactory();
const resultDaily = yield call(api, daily);
yield put(registerDaily.succeed(daily, resultDaily));
} catch (error) {
yield put(registerDaily.fail(daily, error));
}
}
function* runGetDailies(action: ReturnType<typeof getDailies.start>) {
try {
const api = getDailiesFactory();
const resultDailies = yield call(api);
yield put(getDailies.succeed(resultDailies));
} catch (error) {
yield put(getDailies.fail(error));
}
}
// take … 特定のActionを待ち受ける.
// takeEvery … 渡されたActionの数だけ律儀にタスクを実行する
// takeLatest … 最新のものだけを実行する
export function* watchPostDaily() {
yield takeLatest(REGISTER_DAILY_START, runPostDaily);
}
export function* watchGetDailies() {
yield takeLatest(GET_DAILIES_START, runGetDailies);
}
// これをSagaミドルウェアに渡すとアプリ起動時に同時に起動されて,ここでforkされた分だけ別のタスクも立ち上がってスタンバイする.
export default function* rootSaga() {
yield all([fork(watchPostDaily), fork(watchGetDailies)]);
}
(6) 最上位のコンポーネントに,全コンポーネントでsagaを利用できるような仕組みを実装する.
最上位コンポーネントである,src/index.tsx
にsagaを利用できる仕組みを実装します.
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { BrowserRouter } from 'react-router-dom';
import createSagaMiddleware from 'redux-saga';
import App from './App';
import dailyReducer from './reducer';
import rootSaga from './sagas/daily';
import * as serviceWorker from './serviceWorker';
import './index.css';
import './styles/semantic.min.css';
// 以下2行を追加
const sagaMiddleWare = createSagaMiddleware();
const store = createStore(dailyReducer, applyMiddleware(sagaMiddleWare));
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root') as HTMLInputElement,
);
serviceWorker.unregister();
// これも追加
sagaMiddleWare.run(rootSaga);
(7) 最後に,state有コンポーネント((1)をimportして,機能を追加するコンポーネント)を作成する.
最後に,state有コンポーネント(通称コンテナ)を作成して,storeとコンポーネントのpropsを結びつける作業をします.
src/containers/*.tsx
が該当のコンテナです.ここではsrc/containers/WriteDailyPart.tsx
をみていきましょう.
import React, { FC } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { RouteComponentProps, withRouter } from 'react-router';
import WriteDailyPart, { WriteDailyProps } from '../components/WriteDailyPart';
import { Daily } from '../services/daily/model';
import { DailyState } from '../reducer';
import { registerDaily } from '../actions/daily';
interface StateProps {
dailies: Daily[];
}
interface DispatchProps {
postDaily: (daily: Daily) => void;
}
type EnhancedMembersProps = WriteDailyProps &
StateProps &
DispatchProps &
RouteComponentProps;
const mapStateToProps = (state: DailyState): StateProps => ({
dailies: state.dailies,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps =>
bindActionCreators(
{
postDaily: (daily: Daily) => registerDaily.start(daily),
},
dispatch,
);
const DailyContainer: FC<EnhancedMembersProps> = ({ dailies, postDaily }) => {
const postDailyTmp = () => {
const today = document.getElementById('today') as HTMLInputElement;
const tomorrow = document.getElementById('tomorrow') as HTMLInputElement;
const point = document.getElementById('point') as HTMLInputElement;
const todayDate = new Date();
const date =
`${todayDate.getFullYear()}` +
`${('0' + (todayDate.getMonth() + 1)).slice(-2)}` +
`${todayDate.getDate()}`;
const daily: Daily = {
id: 3,
date,
today: today.value,
tomorrow: tomorrow.value,
point: point.value,
};
postDaily(daily);
};
return <WriteDailyPart postDailyTmp={postDailyTmp} />;
};
// DailyContainerの(state, dispatch)と,storeやactionで管理している(state, dispatch)を結びつける.
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(DailyContainer),
);
これで完成です.
最後に
redux-sagaは概念自体はそこまで難しくないのですが,実装するのはかなり難しかったです.
私はこの本で学習することにより,体系的にreact, redux, redux-sagaを学ぶことができました.
おすすめなので,この記事でreact, redux, redux-sagaに興味を持った方は,一度ご購入を検討してみてください!