BETA

ポートフォリオちゃんの命の輝きを見よ!

投稿日:2018-12-06
最終更新:2018-12-06
※この記事は外部サイト(https://qiita.com/roottool/items/e0eb5a938...)からのクロス投稿です

前置き

前回の記事から、ポートフォリオが色々変わりました。
色々変えた際のインプットをアウトプットしていこうと思います。

具体的には何が変わった?

  • 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を実装したかに焦点を当てたいと思います。

完成品

roottool's Portfolio
ソースコード

画面サンプルは前回の記事をご覧下さい。

開発環境

  • Windows 10 Pro
  • Visual Studio Code
  • Node.js:v10.13.0
  • npm: v4.0.5

使ったパッケージ

多いのでpackage.jsondependenciesdevDependenciesを載せます。

"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 + 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は、全体のStateActionの型定義にconnected-react-routerRouterStateRouterActionを追加して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  
);  

ProviderConnectedRouterに対してstore.tsで作成したstoreexportした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は、以下の順で処理を行います。

  1. take()/src/Pages/Hobbies/index.tsxthis.props.actions.requestFetchingUserOwnedGameInfo();が実行されるのを待つ。
  2. fetchOwnedGamesApiでAPIから所有ゲーム一覧を取得する。
  3. 2番の処理完了待ち
  4. 2番の処理によって取得した所有ゲーム一覧を、sortOwnedGamesがプレイ時間で降順ソートする。
  5. 4番の処理完了待ち
  6. 4番の処理によってソートされた所有ゲーム一覧を、receiveFetchedUserOwnedGameInfoStoreに格納する。

ここで躓いた点が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.tsexportしていたsagaMiddlewarerun()メソッドにて、作成したSagaを起動させて完了です。

実装した感想

reduxを学習することに対して、大変そうだと抵抗感がありました。
しかし、使ってみるとStoreの一元管理によってコンポーネントの複雑さが軽減されたので学習コストをかけた分のメリットがあったと感じました。

最後に

Steamerの皆さん、ポートフォリオを作ってご自慢のSteamライブラリを全世界に晒しましょう!

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@roottoolの技術ブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
目次をみる
技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
or 外部アカウントではじめる
10秒で技術ブログが作れます!