コード書き始めてから1年くらいのNoobです。ぼちぼち、create-react-app
にまかせっきりのスタイルから脱却を図ります。ついでに、JavaScript(以下js)で静的型付けを実現するTypeScript(以下ts)、状態管理周りのごちゃごちゃも一掃するためのRedux、それで非同期処理を行うためのRxJS6も導入します。
create-react-app
をeject
し、とりあえずtsとReduxを使った簡単なページを作成します。
学ぶこと
- TypeScriptの書き方
- Reduxの基本的な使い方
- Reduxでの非同期処理(redux-observable)
- ReactとTypeScriptとReduxの連携
この記事の対象読者としては、create-react-app
で何か作ったことがあって、jsについて基本だけはわかるという人を想像しています。基本的に読んだ記事へのリンクを至るところに貼ってあるので、すべて読めば最低限自分と同じくらいにはなると思います。
環境
- npm -v 6.4.1
npmはnpxが使えるバージョンであれば良いです。またパッケージ管理にはYarnを使っています。かわいいので。いやな人は適宜npmに置き換えてください。
Let's get started
ジャバ・ザ・ハットリさんの記事に書かれているReact+Reduxのサンプルと同じ内容を、TSで書き換えたり、非同期処理を切り出したりすることで学んでいきます。Reduxについてかなりわかりやすい記事なので、一読することをおすすめします。
npx create-react-app test-ts --scripts-version=react-scripts-ts
cd test-ts
yarn eject
yarn add redux react-redux
yarn add --dev @types/react-redux
yarn start // 上手く起動するかテスト
// 表示が確認できたら閉じる
react-reduxはReactとReduxを繋いでくれるライブラリです。@types/react-reduxは、react-reduxの型が定義されたファイルで、tsには必須です。
さらに、tsでReduxを使う上で、いろいろ楽にしてくれるライブラリを追加します。typescript-fsaとtypescript-fsa-reducersです。
yarn add typescript-fsa typescript-fsa-reducers
まずTypeScriptについて概要を押さえておく
これからいきなりtsを書いていくので、一応概要は押さえておく必要があります。以下を参考にしてください。
-
初心者が学ぶ TypeScript 入門 Ver.1.0
入門。さらっと読んでtsなんぞ、の理解を深める。 -
関東最速でReact+Redux+TypeScriptなアプリを作る
tsとReactの連携はこうやるのかぁ的に、大枠を理解する。この記事もこちらを参考にした箇所多し。 -
TypeScriptの型入門
tsのチートシート。量があるので全ては読まず、わからない箇所が出てきたら適宜振り返る形式で良いが、オブジェクト型ぐらいまではあからじめ知っておくと便利かも。
おおまかに理解したら、とりあえず進みましょう。とりあえず記事通りに書けば動きはしますので。
Reduxについての理解と導入
「React+Redux入門」と「Reduxの実装とReactとの連携を超シンプルなサンプルを使って解説」を読んで、Reduxの概要を理解します。細かい実装に関しては、今回は記事と違いtsである、また記事が多少古いことも鑑みて気にしなくてよいです。Reduxとはどういうものかについてコードベースで大まかに理解できたら、今回の目標サンプルコード再度見返します。何をやっているのか理解できるはずです。
Reduxを導入することで、viewがaction(APIを叩くなど)と分離して綺麗になるなぁと感じてきたら、いい頃合いです。
個人的な理解を整理すると、
- storeを介してstateが管理されている(1つのアプリに1つのstore)
- reducerでstoreのstateの遷移を行う(stateに触れられるのはここ)
- actionがフロントで叩かれることでreducerが反応する
- containerがreduxと連携し、componentはviewのみに徹する
ここでしっかりReduxについて理解しておかないと、今回はtsもReactive Xなんて概念も盛り込んでいるので、後々辛くなります(というか自分はなりました笑)。分かんなくなったら、適宜振り返ります。
そこまでいったら、実際に書いていきます。まずはこちらを参考にフォルダを作成します。
mkdir src/{actions,reducers,containers,components}
touch src/{containers,components}/Comments.tsx
touch src/{actions,reducers}/comments.ts
touch src/reducers/index.ts
touch src/store.ts
mkdir src/api
touch src/api/getComments.ts
Actionを作成する
tsを使う場合では型定義が必要になるので、普通にactionを書くと型定義も含めかなり煩雑になりそうです。そこで、typescript-fsaを使います。
これによりactionを簡単に書け、その呼び出しも楽になります。
import actionCreatorFactory from 'typescript-fsa';
import { IComments } from '../api/getComments';
const actionCreator = actionCreatorFactory();
const commentsActions = {
fetch: actionCreator.async<{ url: string }, { comments: IComments[] }, { hasError: boolean }>('FETCH_COMMENTS'),
loading: actionCreator<{ isLoading: boolean }>('LOAD_COMMENTS'),
};
export default commentsActions;
asyncな場合のactionに関しては公式ドキュメントのこの辺に書かれています。また、apiの箇所に関してはあとで作るので今は無視しておいてください。
Reducerを作成する
次にreducerです。ここでstateの遷移が行われるため、stateの初期値の定義などもここで行います。typescript-fsa-reducersを使うことで、条件分岐を執拗に書かなくて良くなるので導入しています。
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import commentsActions from '../actions/comments';
import { IComments } from '../api/getComments';
export interface ICommentsState {
hasError: boolean;
isLoading: boolean;
comments: IComments[];
}
const initialState: ICommentsState = {
hasError: false,
isLoading: false,
comments: [],
};
export const commentsReducer = reducerWithInitialState(initialState)
.case(commentsActions.loading, (state, payload) => {
return Object.assign({}, state, { isLoading: payload.isLoading });
})
.case(commentsActions.fetch.done, (state, payload) => {
return Object.assign({}, state, { comments: payload.result.comments })
})
.case(commentsActions.fetch.failed, (state, payload) => {
return Object.assign({}, state, { hasError: payload.error.hasError })
})
export default commentsReducer;
ここのICommentsState
はinterfaceとして後から利用するのでexport
しておきます。大文字のIが付くのは、interfaceを宣言する時の慣習のようなもので、これをやらないとtslintで怒られることがあります。
Object.assignについてはMDNを参照してください。ここでObject.assign(state, newState)
とせずにObject.assign({}, state, newState)
としている理由は、元のstateを"上書き"するのではなくあくまで"変更"する必要があるからです。"変更"にすることで、Reduxがその変更を検知し、ReactがViewに反映します。
この.case
はtypescript-fsa-reducersに含まれるメソッドです。これにより、煩雑な条件分岐を書かなくてよくなります。.case
内のisLoading
やcomments
、hasError
には、後で書く処理によりactionのpayloadが代入されます。payloadは、asyncな処理が成功した場合にのみ少し異なるため注意が必要です。
また、Reducerが増えた時に備え、それらをまとめる"reducers/index.ts"も作っておきます。
import { combineReducers } from 'redux';
import { ICommentsState, commentsReducer } from './comments';
export interface IAppState {
comments: ICommentsState
}
export const reducers = combineReducers<IAppState>({ comments: commentsReducer })
export default reducers;
ここのIAppState
が、storeに格納するstateになります。これをstore経由で個別のcontainerに伝え、その中で必要なstateのみを展開することでcomponentにpropsが伝わります。
Storeを作成する
storeはreducersから作成するだけで良いので簡素です。
import { createStore } from 'redux';
import reducers from "./reducers/index";
const store = createStore(
reducers
);
export default store;
このstoreをReactのcontainerが直接的に参照することで、propsが伝わります。
ReactとReduxの連携
ReactがReduxと関係を持つ場所としては、srcフォルダ以下のメインとなる"index.tsx"とcontainerの2つがあります。
"index.tsx"とReduxの連携は簡単です。react-redux
のProvider
を使ってstore.ts
を流し込むだけ。
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import App from './App';
import store from './store';
import './index.css';
import registerServiceWorker from './registerServiceWorker';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
registerServiceWorker();
<Provider store={store}>
の箇所でstoreを流し込んでいます。App.tsx自体はcontainerをラップしているだけのcomponentです。
import * as React from 'react';
import Comments from './containers/Comments';
const App = () => {
return (
<div>
<Comments />
</div>
)
}
export default App;
次にReduxとcontainerの連携です。
ここで連携するのは、「Reduxのstoreで管理されているstate」と「stateに変更を加えるためのreducerを操作するaction」の2つです。
import * as React from 'react'
import { Dispatch } from 'redux';
import { connect } from 'react-redux'
import { AppState } from '../reducers/index'
import commentsActions from '../actions/comments'
import * as AppComponent from '../components/app'
const mapStateToProps = (appState: AppState) => {
return Object.assign({}, appState.comments);
};
const mapDispatchToProps = (dispatch: Dispatch<any>) => {
return {
fetchData: (url: string) => dispatch(commentsActions.fetch.started(url))
}
};
// Container
const App = connect(
mapStateToProps, mapDispatchToProps
)(AppComponent)
export default App;
containerには、react-reduxのconnectにより、storeからstateが伝わります。そのstateは先ほど"src/reducers/index.ts"の箇所で説明した、IAppState
の型を取ります。つまり、以下のような想定です。
// src/containers/Comments.tsx内のappState
{
comments: {
hasError: boolean;
isLoading: boolean;
comments: IComments[];
}
}
// なぜなら、appStateはsrc/reducers/index.tsにて以下のような型で定義されており
interface IAppState {
comments: ICommentsState
}
// src/reducers/comments.ts内でICommentsStateは以下の型と定義されているから
interface ICommentsState {
hasError: boolean;
isLoading: boolean;
comments: IComments[];
}
このように型定義されていると、オブジェクトが深くなっても正確に追えるので便利だなぁと、ここで感じました。
mapStateToProps()
では、commentsのcontainerで必要なstateのみ、つまりappState.comments
を取り出しています。
mapDispatchToProps()
では、comments containerに関係するactionのみをstateに入れます。dispatch(action)
とすることで、そのactionを割り当てることができます。ここでは、componentでfetchData()
を実行すると、commentsActions.fetch.started()
というactionに基づきstateが変化するようになります。
それらをconnect()()
で、状態を持たない純粋なcomponentと結びつけてcontainerは完了です。
もしReactから上がってきた値を直接的にstateに入れるのではなく、何か手を加えたいのであれば、このmapDispatchToProps()
内で加工するのも1つの手です(例えば、正規表現で余分な箇所を削るなど)。
以上で、基本的な箇所は完了です。しかし、Reactのcomponentが受け取った値を使用して非同期処理などの複雑な処理を行いたい場合は別途、どうにかする必要があります。
Reduxでの非同期処理
今回問題となるのが、APIからコメントデータを取ってくるfetchComments()
です。これを実装するためには、Reduxにおける非同期処理の方法を学ぶ必要があります。こちらの記事などを参考にしたところ、どうやらReduxでは、非同期処理を実装する方法として以下の4つが考えられるようです。
- コンポーネント内に直接書く
- ReduxのActionに書く(redux-thunk)
- Sagaという別のプロセスに組み込む(redux-saga)
- RxJSを噛ませる(redux-observable)
大した処理がないものではcomponentに組み込む方法がベストですが、勉強も兼ねてredux-observableを使ってみます。最初はredux-sagaが、完全に別のプロセスに分離できそうでいいなぁと思っていたのですが、この記事を読んで「RxJSヤバくね?」と単細胞にも感じたので、redux-observableに決定。
redux-observableの導入
redux-observaleは、Reduxのactionが実行されたときに行う複雑な処理、特に非同期処理をRxJSで処理するために必要となるライブラリです。Reduxのミドルウェアとして機能します。
導入にあたり、typescript-fsa-redux-observableを導入するか否かはどっちでも。これを導入することでの旨味は多いようなので、入れてもいいかもしれません。今回は入れません。
また、公式の注意書きにもありますが、redux-observableを使うためにはRxJS v6について多少の知識が必要です。RxJsについては、このLIGの記事で何となく凄いライブラリだなぁ感じ取ってから、Qittaの逆引きredux-observableで概要を掴みました。一番参考になったのは、リクルートのエンジニアブログのRxJSを学ぼうです。一通り軽く読み流すと、わかってきます。また、WEB上のドキュメントはRxJS v5.5以下の情報が多いですが、RxJS v6では.pipe
使う必要があったりと前バージョンからの破壊的な変更が多いです。実装する際は、RxJSのバージョンが違うドキュメントを鵜呑みにすると動かないので注意です。
追々、RxJS公式ドキュメントも読んでいきたいと思いますが、まずは実装してみます。実装にあたっては、redux-observableのドキュメント、およびtypescript-fsaのドキュメントを参考にして進めます。
RxJSのajax関数を使った非同期処理が公式では紹介されていますが、fetch
でPromiseを返し、それをObservableに変換することもできます。お好きなほうを。今回はAPIを切り出す方法で行きます。では必要なファイルなどを導入します。
yarn add redux-observable rxjs
mkdir src/epics
touch src/epics/{comments.ts,index.ts}
mkdir src/api
touch src/api/getComments.ts
まずはAPI部分
特に変わったところはありません。APIはここで使われているもので、コメントをjsonで撮ってきます。IComments
はそのinterfaceです。APIに関わることなので、ここで型定義をしています。
export interface IComments {
id: string;
comment: string;
}
const getComments = async (url: string) => {
return await fetch(url)
.then(response => response.json())
.then(comments => comments)
.catch((e: string) => e);
}
export default getComments;
Epicを作成する
redux-observableのコア部分であるEpicを作成していきます。
import { Action } from 'redux';
import { of, from, concat } from 'rxjs';
import { map, mergeMap, /*catchError*/ } from 'rxjs/operators';
import { Epic, ofType } from 'redux-observable';
import { IComments } from '../api/getComments';
import commentsActions from '../actions/comments';
import getComments from '../api/getComments';
interface IPayloadAction extends Action {
type: string;
payload?: any;
}
const commentsFetchEpic: Epic<IPayloadAction> = (actions$) => actions$.pipe(
ofType(commentsActions.fetch.started.type),
mergeMap((action: IPayloadAction) => concat(
of(commentsActions.loading({ isLoading: true })),
from(getComments(action.payload.url)).pipe(
map((comments: IComments[]) => commentsActions.fetch.done({ params: action.payload.url, result: { comments } })),
// TODO: 以下ではError handlingができない
// catchError(error => of(commentsActions.fetch.failed({ params: action.payload.url, error: { hasError: true } }))),
),
of(commentsActions.loading({ isLoading: false })),
)),
);
export default commentsFetchEpic;
まずIPayloadAction
ですが、これはEpicが受け取るReduxのactionのinterfaceになります。これを型定義しておくことで、Property 'payload' does not exist
などのようなエラーを避けることができます。
ofType()
はaction.type === 'ACTION_TYPE'
のredux-observableにおけるシンタックスシュガーです。
mergeMap
(flatMap
と同じもの)は、APIを呼び出す際などに使います。ここではmergeMap
内で複数のactionを呼び出したかったので、concat()
で繋げています。concat()
は複数のobservableを1つのobservableにします。from()
は、PromiseやArrayをobservableに変換する役割です。ここではgetComments()
APIをobservableにして.pipe()
で流し、map()
でactionを実行します。asyncなactionの取り扱いについては、typescript-fsaのドキュメントより確認してください。
この非同期処理の全体の流れはこのissueを参考にしており、それによるとstartWith()
やendWith()
を使った解決方法のほうがコードの透視は良さそうです。しかし、endWith()
でcommentsActions.loading({ isLoading: false })
を実行しようとしても上手くいかなかったため、素直に流しています。
自分はこのRxJSの箇所でかなり詰まりました。RxJS難しい。。。
いまだ、エラーハンドリングが上手くいかずに困っているので、どなたかお助け願います!!
結局、ajax
を使うことで解決しました。公式にもその方法が載っていた。。
ajax.getJSON(action.payload.url).pipe(
map((comments: IComments[]) => commentsActions.fetch.done({ params: action.payload.url, result: { comments } })),
catchError(_ => of(commentsActions.fetch.failed({ params: action.payload.url, error: { hasError: true } }))),
),
それにともない、"api/getComments"は削除してよいです。
Epicsをまとめる
他に処理が増えるにしたがいEpicsも増えていくため、別途結合し、それをstoreに流しこみます。
import { combineEpics } from 'redux-observable';
import commentsFetchEpic from './comments';
const epics = combineEpics(
commentsFetchEpic
);
export default epics;
combineEpicsは複数のEpicをまとめてくれます。これを、storeにミドルウェアとして流し込みます。
import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';
import reducers from "./reducers/index";
import epics from './epics/index';
const epicMiddleware = createEpicMiddleware();
const store = createStore(
reducers,
applyMiddleware(epicMiddleware)
);
epicMiddleware.run(epics);
export default store;
ここは公式ドキュメントの通りです。
これで完成となります。yarn start
で起動したら、コメントが表示される。。と思いきや、tslintの設定で怒られるので、設定を少しいじってみます。
tslint関連の設定を少し直す
起こられたままだと上手く表示できないので、直すか設定を変更するかします。「interfaceのnameはIで始まらなければならない」、「objectリテラルとか、ショートハンド使えよ」
({comments: comments}
ではなく、{ comments }
使えよってこと)、「typeよりinterface使えよ、普通は」、以上の3つに関しては1度怒られたのですが、良さそうなlintだったので準拠しました。
tslint:「impotなどをアルファベット順に書けよ」をオフにする
アルファベット順にimportが書かれていないと、Import sources within a group must be alphabetized.
のようなエラーが大量発生します。こいつが効いているようです。
あまり自分は重要性を感じなかったので、オフにしました。
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"linterOptions": {
"exclude": [
"config/**/*.js",
"node_modules/**/*.ts",
"coverage/lcov-report/*.js"
]
},
"rules": {
"ordered-imports": [false],
"object-literal-sort-keys": [false]
}
}
以上で本当に完了です。問題なければ、yarn start
で以下のような感じに表示されるはずです。