「グレンジ Advent Calendar 2018」18日目を担当するy244です。
株式会社グレンジにてフロントエンジニアをしております。
#概要
redux-sagaによる非同期通信のQiitaの記事はあるが断片的なものが多く、いざ設計となると悩む事が多かったのでまとまった形でのサンプルコードを用意してみました。
非同期通信の処理をどこでもつかというところが主な内容となっています。
【今回のサンプルの仕様】
ログインフォームからユーザーの入力内容(email,password)を任意のAPIに投げてサーバーからのレスポンスで処理を行う
#対象
react, redux, redux-sagaの仕組みはある程度わかったけど運用前提の設計をどうしようか悩んでいる方
##ディレクトリ構成
public
└index.html
src
├actions.js
├common
│ └api.js
├components
│ └login-form.js
├reducers
│ ├index.js
│ └login.js
├sagas
│ ├index.js
│ └loginSaga.js
├containers
│ └login.js
├index.js
└router.js
package.json
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<title>sample login form</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>
##index,router
import React from 'react';
import ReactDOM from 'react-dom';
import Router from './router';
ReactDOM.render(<Router />, document.getElementById('root'));
import React from 'react';
import { Provider } from 'react-redux';
import { HashRouter, Route, Switch } from 'react-router-dom';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { routerMiddleware } from 'react-router-redux';
import routerReducer from './reducers/index';
import rootSaga from './sagas/index';
import Login from './containers/login';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
routerReducer,
composeEnhancers(applyMiddleware(sagaMiddleware))
);
sagaMiddleware.run(rootSaga);
// ステートが変更されるたびにログ出力
store.subscribe(() =>
console.log("store", store.getState());
);
function Router() {
return (
<Provider store={store}>
<HashRouter>
<React.Fragment>
<Switch>
<Route path='/login' component={Login} />
</Switch>
</React.Fragment>
</HashRouter>
</Provider>
);
}
export default Router;
##Action
redux-actionsを用いて記述を簡略化しActionCreatorを生成しています。
import { createAction } from 'redux-actions';
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
export const loginRequest = createAction(LOGIN_REQUEST);
export const loginSuccess = createAction(LOGIN_SUCCESS);
export const loginFailure = createAction(LOGIN_FAILURE);
##Reducer
reducers/index.jsのcombineReducersにてReducerをまとめています。
(今回はReducerは一つですが複数の分割ファイルになるのを想定しての設計になっています)
import { combineReducers } from 'redux';
import login from './login';
// Reducer Index
const rootReducer = combineReducers({
login,
});
export default rootReducer;
const initialState = {};
export default function login(state = initialState, action) {
switch (action.type) {
case 'LOGIN_SUCCESS':
// ログイン成功時に呼ばれる
state = action.payload;
return state;
case 'LOGIN_FAILURE':
// ログイン失敗時に呼ばれる
state = action.payload;
return state;
default:
return initialState;
}
}
##Saga
sagas/index.jsにて複数のsagaファイルをまとめています。
import { all } from 'redux-saga/effects';
import loginSaga from './loginSaga';
export default function* rootSaga() {
yield all([
...loginSaga,
]);
}
import { call, put, takeEvery } from 'redux-saga/effects';
import api from '../common/api';
import { loginSuccess, loginFailure } from '../actions/actions';
function* handleRequest(action) {
let { payload } = action;
payload = yield call(api, payload);
// サーバーからのレスポンスデータによる分岐処理
if( ログイン成功か失敗かの条件 ) {
// 成功時アクション呼び出し
yield put(loginSuccess(payload));
} else {
// 失敗時アクション呼び出し
yield put(loginFailure(payload));
}
}
// 「LOGIN_REQUEST」アクションが呼ばれるのを待つ呼ばれたhandleRequestを実行
const loginSaga = [takeEvery('LOGIN_REQUEST', handleRequest)];
export default loginSaga;
##Components
Presentational component見た目だけを扱うComponent
import React, { Component } from 'react';
class LoginForm extends Component {
constructor(props) {
super(props);
this.state = {
loginForm: {
email: "",
password: ""
}
}
this.handleChange = this.handleChange.bind(this);
}
// ログイン入力内容の変更を監視
handleChange(event) {
let loginForm = this.state.loginForm;
switch (event.target.name) {
case 'login-email':
loginForm.email = event.target.value;
break;
case 'login-password':
loginForm.password = event.target.value;
break;
default:
break;
}
// 状態を更新
this.setState({
loginForm: loginForm
});
}
componentDidMount(){
let loginForm = this.state.loginForm;
this.setState({
loginForm: loginForm
});
}
render() {
const { dispatchRequest } = this.props;
return (
<form>
<div>
<div>
<input type="email" name="login-email" placeholder="メールアドレス" value={this.state.loginForm.email} onChange={this.handleChange} />
<input type="password" name="login-password" placeholder="パスワード" value={this.state.loginForm.password} onChange={this.handleChange} />
</div>
<div>
<button type="button" disabled={false} onClick={e => dispatchRequest(this.state.loginForm)}>ログイン</button>
</div>
</div>
</form>
);
}
}
##Container
Container component componentのロジカルな部分を担う
具体的なデータの取り扱いstoreからのコールバックを
Presentational componentへ受け渡す
import React, { Component } from 'react';
import SectionTitle from '../components/section-title';
import { connect } from 'react-redux';
import LoginForm from '../components/login-form';
import { loginRequest } from '../actions';
class Login extends React.Component {
render() {
return (
<section>
<SectionTitle title="ログイン" />
<LoginForm dispatchRequest={this.props.dispatchRequest} />
</section>
);
}
}
function mapStateToProps(state) {
return state.login;
}
function mapDispatchToProps(dispatch) {
return { dispatchRequest: loginForm => dispatch(loginRequest(loginForm)) }
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Login);
##API
非同期通信部分はFetch APIを使用
function api(data) {
return fetch('https://xxxxxxxxxx', {
method:'post',
mode: 'cors',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify(data)
})
.then(res => (res.json()))
.catch(error => ({ error }));
}
export default api;
##package.json
{
"name": "sample",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^16.6.0",
"react-dom": "^16.6.0",
"react-router-dom": "^4.3.1",
"react-scripts": "2.0.5",
"redux-actions": "^2.6.4",
"redux-saga": "^0.16.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
},
"devDependencies": {
"@babel/core": "^7.1.2",
"babel-loader": "^8.0.4",
"fetch": "^1.1.0",
"node-sass": "^4.9.4",
"react-redux": "^6.0.0"
}
}
#まとめ
サーバー側が用意するAPI毎にactions.jsへのactionの追加とreducer,sagaファイルを追加していく形になっています。
今回、Actionは1ファイルにまとめ、Reducerは分割で持つ形にしていますが
Action,Reducerは対のものなのでAction,Reducerで一つのファイルにまとめ用途ごとに分割の形でも良いと思いました。