前置き
前回の記事から、ポートフォリオが色々変わりました。
色々変えた際のインプットをアウトプットしていこうと思います。
具体的には何が変わった?
- redux, react-reduxの導入
- connected-react-routerを導入
- redux-sagaの導入
- reactstrapからMaterial-UIに変更
- CSS→SASS→styled-componentsに流れ着く
この記事で書くこと
この記事では、下記の3点に絞って書きます。
- redux, react-reduxの導入
- connected-react-routerを導入
- redux-sagaの導入
ポートフォリオはReact + Redux + TypeScriptの最小構成を写生して作り変えました。
そのため、React + Redux + TypeScriptの最小構成に対してどのようにconnected-react-routerやredux-sagaを実装したかに焦点を当てたいと思います。
完成品
画面サンプルは前回の記事をご覧下さい。
開発環境
- Windows 10 Pro
- Visual Studio Code
- Node.js:v10.13.0
- npm: v4.0.5
使ったパッケージ
多いのでpackage.json
のdependencies
とdevDependencies
を載せます。
"dependencies": {
"@material-ui/core": "^3.5.1",
"@material-ui/icons": "^3.0.1",
"@types/jest": "23.3.9",
"@types/node": "10.12.2",
"@types/react": "^16.7.11",
"@types/react-dom": "16.0.9",
"@types/react-helmet": "^5.0.7",
"@types/react-redux": "^6.0.10",
"@types/react-router-dom": "^4.3.1",
"@types/styled-components": "^4.1.2",
"axios": "^0.18.0",
"connected-react-router": "^5.0.1",
"node-sass": "^4.9.4",
"react": "^16.6.0",
"react-dom": "^16.6.0",
"react-helmet": "^5.2.0",
"react-icons": "^3.2.2",
"react-redux": "^5.1.1",
"react-router-dom": "^4.3.1",
"react-scripts": "2.1.1",
"redux": "^4.0.1",
"redux-saga": "^0.16.2",
"styled-components": "^4.1.2",
"typescript": "3.1.6"
},
"devDependencies": {
"gh-pages": "^2.0.1",
"redux-devtools-extension": "^2.13.6"
}
redux, react-reduxの導入
reduxは、状態(State)管理を行うパッケージです。
そのreduxをReactで使用出来るようにするパッケージがreact-reduxです。
reduxの大まかな流れは以下のようになります。
引用元:Redux. From twitter hype to production
Reactでは各コンポーネント単位で状態を管理することが出来ます。
interface IState {
testState: boolean;
}
export default class Example extends Component<{}, IState> {
constructor(props: IProps) {
super(props);
this.state = {
testState: false
};
}
private changeState = () => this.setState({testState: true});
...
}
しかし、コンポーネントの数が多くなるに従って各コンポーネントの状態を把握するのは難しくなります。(コンポーネント数が数個ではなく10個、数十個となった時に、全てのコンポーネントの状態管理をメンテナンスするのは辛いと思います。)
そこで、各コンポーネントの状態を一元管理しようというのがReactにおけるreduxの使い方になります。
参考資料
reduxについては、以下の記事を参考にしました。
上から順に読んでいくことをオススメします。
自分でreduxとは何かを書かなくても、以下の記事を読んでもらえば良いかなと思いました
- React Redux の SPA を運用して得られた知見と実装例、開発フローもあるよ! - redux
- たぶんこれが一番分かりやすいと思います React + Redux のフロー図解
- React + Redux + TypeScriptの最小構成
React + Redux + TypeScriptの最小構成を写生したので、今回のポートフォリオでのreduxの実装方法については説明は割愛します。
connected-react-routerの導入
connected-react-routerは、react-routerでのページ遷移の状態管理をreduxで行うパッケージです。
こういったルーティングの状態管理には、react-router-reduxを使用してる方が多いと思います。
しかし、react-router-reduxの使用を非推奨とするので、connected-react-routerを使ってくださいと公式ドキュメントに記載があったので使用しました。
導入方法
以下のコマンドを実行してインストールします。
npm install --save connected-react-router
実装方法
実装、その前に
公式ドキュメントには実装するにはStep 1とStep 2を行う必要があると書かれています。
Step 1には以下のように書かれています。
1. history
オブジェクトを作る
2. root reducer
ファンクションを作って、history
オブジェクトを引数にする。
3. 引数のhistroy
オブジェクトを使ってroot reducer
内にrouter reducer
を追加してconnectRouter
に渡す。注意: router
キーを必ず付ける。
4. (push('/path/to/somewhere')
)のようなhistory
アクションをディスパッチしたい場合は、routerMiddleware(history)
を使用する。
import { combineReducers } from 'redux'
import { connectRouter } from 'connected-react-router'
export default (history) => combineReducers({
router: connectRouter(history),
... // rest of your reducers
})
...
import { createBrowserHistory } from 'history'
import { applyMiddleware, compose, createStore } from 'redux'
import { routerMiddleware } from 'connected-react-router'
import createRootReducer from './reducers'
...
const history = createBrowserHistory()
const store = createStore(
createRootReducer(history), // root reducer with router state
initialState,
compose(
applyMiddleware(
routerMiddleware(history), // for dispatching history actions
// ... other middlewares ...
),
),
)
上記に対し、connected-react-router
を導入する前のポートフォリオではstoreのコードが以下のようになっていました。
import { createStore, combineReducers, Action } from 'redux'
import app, { AppActions, AppState } from "./module";
export default createStore(
combineReducers({
app
})
)
export type ReduxState = {
app: AppState;
};
export type ReduxAction = Action | AppActions;
root reducer
がありませんが、実態はcombineReducers()
で各reducerの結合のみを行っているとソースから読み取ったので、Step 1の2番目に書かれているroot reducer
ファンクションを作成せずに実装します。
Step 1の実装
まず、結果を記載します。
import {
Action,
applyMiddleware,
compose,
createStore,
combineReducers
} from "redux";
import { createBrowserHistory } from "history";
import {
RouterState,
routerMiddleware,
connectRouter,
RouterAction
} from "connected-react-router";
// redux関連
import app, { AppActions, AppState } from "./module";
// ポイント1
export const history = createBrowserHistory();
export default createStore(
combineReducers({
// ポイント2
router: connectRouter(history),
app
}),
// ポイント3
compose(applyMiddleware(routerMiddleware(history)))
);
export type ReduxState = {
// ポイント4
router: RouterState;
app: AppState;
};
// ポイント3
export type ReduxAction = Action | RouterAction | AppActions;
まずポイント1ですが、history
オブジェクトを作成します。export
している理由は後述します。
ポイント2は、router
キーを忘れず記入してhistory
オブジェクトをconnectRouter()
に渡します。
ポイント3は、routerMiddleware(history)
をミドルウェアを使ってreduxのStoreに接続します。
最後のポイント4は、全体のState
とAction
の型定義にconnected-react-router
のRouterState
とRouterAction
を追加してStep 1の部分は完了です。
Step 2の実装
Step 2部分の実装は以下のようになります。
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { ConnectedRouter } from "connected-react-router";
import App from "./Container";
import store, { history } from "./store";
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>,
document.getElementById("root") as HTMLElement
);
Provider
とConnectedRouter
に対してstore.ts
で作成したstore
とexport
したhistory
を渡して実装完了です。
redux-sagaの導入
redux-sagaはredux, react-reduxの導入で引用した図のMiddlewareの部分になるパッケージです。
今回はSteam Web APIからSteamで所有しているゲーム一覧を取得して表示するという、非同期処理を実行するために使用しました。
Steam Web APIの使用方法は本題から逸れるため、割愛します。
参考資料
redux-sagaについては、以下の記事を参考にしました。
導入方法
以下のコマンドを実行してインストールします。
npm install --save redux-saga
実装方法
想定している動作
Hobbiesページを開いた時にSteam Web APIからSteamで所有しているゲーム一覧を取得して表示することです。
これは以下のようになります。
class Hobbies extends Component<IProps, {}> {
constructor(props: IProps) {
super(props);
if (this.props.value.rows && this.props.value.rows.length === 0) {
this.props.actions.requestFetchingUserOwnedGameInfo();
}
}
...
}
Hobbiesページを初めて開いた時だけ実行したいため、if
文で囲っています。
Sagaを作成する
まずは、実装結果を以下に示します。
import { take, put, call, fork } from "redux-saga/effects";
import axios from "axios";
import {
ActionNames,
IUserOwnedGames,
IGamesInfo,
receiveFetchedUserOwnedGameInfo
} from "../Pages/Hobbies/module";
// プレイ時間降順ソート
const sortOwnedGames = (ownedGames: IUserOwnedGames) => {
return ownedGames.response.games.sort(
(leftSide: IGamesInfo, rightSide: IGamesInfo): number => {
if (leftSide.playtime_forever > rightSide.playtime_forever) {
return -1;
} else if (leftSide.playtime_forever < rightSide.playtime_forever) {
return 1;
}
return 0;
}
);
};
const fetchOwnedGamesApi = () => {
const id = "roottool";
const url = `https://example.com/api/?id=${id}/`;
return axios
.get(url)
.then(response => {
return response.data;
})
.catch(error => {
throw new Error(error);
});
};
export function* fetchUserOwnedGameInfo() {
yield take(ActionNames.REQUEST_FETCH);
const ownedGames = yield call(fetchOwnedGamesApi);
const sortedOwendGames = yield call(sortOwnedGames, ownedGames);
yield put(receiveFetchedUserOwnedGameInfo(sortedOwendGames));
}
export default function* root() {
yield fork(fetchUserOwnedGameInfo);
}
順を追って説明します。
まず。fetchUserOwnedGameInfo
というGenerater関数を実行するタスクをfork
によって作成します。
Generator関数は、function*
で定義されます。
Generator関数で定義する理由は、yield
を使って処理の完了を待つことが出来るためです。
作成されたタスクで実行されるfetchUserOwnedGameInfo
は、以下の順で処理を行います。
-
take()
で/src/Pages/Hobbies/index.tsx
のthis.props.actions.requestFetchingUserOwnedGameInfo();
が実行されるのを待つ。 -
fetchOwnedGamesApi
でAPIから所有ゲーム一覧を取得する。 - 2番の処理完了待ち
- 2番の処理によって取得した所有ゲーム一覧を、
sortOwnedGames
がプレイ時間で降順ソートする。 - 4番の処理完了待ち
- 4番の処理によってソートされた所有ゲーム一覧を、
receiveFetchedUserOwnedGameInfo
でStore
に格納する。
ここで躓いた点が1つあります。
fetchOwnedGamesApi
で取得した所有ゲーム一覧がownedGames
に格納されるはずなのに、undefined
になってsortOwnedGames
でエラーが発生したことです。
原因は、return response.data;
のreturn
を書いていなかったからでした。
return axios.get(url).then(...){...}.catch(...){...}
とreturn文をかいていたので、then(...){...}
か.catch(...){...}
の結果がreturn
によって渡されると勘違いしていました。
だからといってreturn axios.get(url).then(...){...}.catch(...){...}
のreturn
を外すとエラーが発生しました。
このことから私はPromise
が含まれるメソッドをyield call()
した場合、Promise
部分をreturn
することでPromise
部分をyield
によって待つように変化するのだと解釈しました。
作成したSagaとReduxのStoreと接続する
最後に作成したSagaを、redux-saga
ミドルウェアを使ってreduxのStoreに接続します。
まずは、公式ドキュメントの実装例を以下に示します。
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import reducer from './reducers'
import mySaga from './sagas'
// ポイント1:Saga ミドルウェアを作成する
const sagaMiddleware = createSagaMiddleware()
// ポイント2:Store にマウントする
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
)
// ポイント3:Saga を起動する
sagaMiddleware.run(mySaga)
// アプリケーションのレンダリング
Javasriptですが、Typescriptであっても実装する内容は同じです。
しかし実装例と違って今回のポートフォリオは、store.ts
にStore部分を分離しています。
従って、今回の実装に合わせた結果は以下のようになります。(関連部分以外省略しています)
...
import createSagaMiddleware from "redux-saga";
...
// ポイント1:Saga ミドルウェアを作成する
export const sagaMiddleware = createSagaMiddleware();
export default createStore(
combineReducers({
router: connectRouter(history),
app,
hobbies
}),
// ポイント2:Store にマウントする
compose(applyMiddleware(routerMiddleware(history), sagaMiddleware))
);
...
ポイント1としていたSagaミドルウェアを作成する部分をexport
していますが、理由は後述します。
作成したSagaミドルウェアをポイント2のように、applyMiddleware()
の()
内に追加したらstore.ts
に対する実装作業は終了です。
次はポイント3で書かれているように、Sagaを起動させます。
今回のポートフォリオでは、/src/
直下にあるindex.tsx
で起動させています。
実装部分を以下に示します。(redux-saga実装に関連していないimport文は省略しています)
...
import rootSaga from "./sagas";
// ポイント3:Saga を起動する
sagaMiddleware.run(rootSaga);
// アプリケーションのレンダリング
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>,
document.getElementById("root") as HTMLElement
);
store.ts
でexport
していたsagaMiddleware
のrun()
メソッドにて、作成したSagaを起動させて完了です。
実装した感想
reduxを学習することに対して、大変そうだと抵抗感がありました。
しかし、使ってみるとStoreの一元管理によってコンポーネントの複雑さが軽減されたので学習コストをかけた分のメリットがあったと感じました。
最後に
Steamerの皆さん、ポートフォリオを作ってご自慢のSteamライブラリを全世界に晒しましょう!