※更新履歴
- webpack2-rc & TS2.1 & @types対応
- webpack2 & TS2.3 対応
- React16-beta & TS2.4 対応
こちらの記事の続編です。
http://qiita.com/uryyyyyyy/items/63969d6ed9341affdffb
問題提起
今時、型のない言語とか使いたくないですよね!(2回目)
ReactといえばRedux(flux実装の実質デファクト)。
ということでTypeScriptでReduxのサンプルを作ってみます。
(reduxのおよその仕組みは他の記事を読んでください。)
環境
- NodeJS 8.2
- React 16.0-beta
- TypeScript 2.4
構成
今回の構成は以下です。
$ tree .
.
├── index.html
├── package.json
├── src
│ ├── counter
│ │ ├── Container.tsx
│ │ ├── Counter.tsx
│ │ └── module.ts
│ ├── Index.tsx
│ └── store.ts
├── tsconfig.json
└── webpack.config.dev.js
ファイル構成についてはReduxのファイル構成は『Ducks』がオススメを参考にして頂ければ良いのではと思います。
Ducksでは、扱いたいモデル毎に
|_ containers
|_ modules
などと分けるのが一般解ですが、今回は簡略のためにページ毎にフォルダを分けるイメージで作ります。
複数ページで状態を共有したくなった場合には別途良い感じに管理してください。
このサンプルでは処理の単位をcounterと置きたいので、そこでディレクトリを分けました。
サンプルコードはこちら。
https://github.com/uryyyyyyy/react-redux-sample/tree/redux
各種設定ファイル
こちらの記事を参照ください。
redux, react-reduxの依存を追加
npm install redux react-redux --save
npm install @types/react-redux --save-dev
Source(Main)
Index.tsx
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import Counter from './counter/Container'
import store from './store'
import {Provider} from 'react-redux'
ReactDOM.render(
<Provider store={store}>
<Counter />
</Provider>
, document.getElementById('app')
)
ここでは、reduxのstoreをProviderの中に入れることで、その子コンポーネントへstoreとdispatch関数が渡ってくるようになります。
そして、Providerの子コンポーネントに当たるContainerでもreduxとの紐付けを行うのですが、そちらは後述します。
store.ts
import counter, {CounterActions, CounterState} from './counter/module'
import {createStore, combineReducers, Action} from 'redux'
export default createStore(
combineReducers({
counter
})
)
export type ReduxState = {
counter : CounterState
}
export type ReduxAction = CounterActions | Action
ここでは、シングルトンのstoreを生成しています。(オブジェクトなので小文字で扱っています。)
createStoreでイベントを処理させるreducerを紐付けておくことで、良い感じにstateを変更してくれるようになります。
(combineReducersを使っているのは、後々reducerが増えることが予想されるからです。)
なお、素のReduxでは型をちゃんと付けている例をほとんど見ないのですが、せっかくなので頑張って型を付けています。馴染みのない方は素通りしても動作には影響ありません。
Source(counter)
ここからcounterフォルダの中に入っていきます。
Container.tsx
import {Counter} from './Counter'
import {connect} from 'react-redux'
import {Dispatch} from 'redux'
import {decrementAmount, incrementAmount} from './module'
import {ReduxAction, ReduxState} from '../store'
export class ActionDispatcher {
constructor(private dispatch: (action: ReduxAction) => void) {}
public increment(amount: number) {
this.dispatch(incrementAmount(amount))
}
public decrement(amount: number) {
this.dispatch(decrementAmount(amount))
}
}
export default connect(
(state: ReduxState) => ({value: state.counter}), // ①
(dispatch: Dispatch<ReduxAction>) => ({actions: new ActionDispatcher(dispatch)}) // ②
)(Counter)
上記Index.tsxで呼ばれるのがこれです。かなりややこしいのでご注意ください。
この中で、react-reduxのconnect関数によってそのstateとdispatchを必要に応じて加工して、Counterコンポーネントに渡しています。
まず①のstateの方ですが、stateの型は書いてある通り、storeで管理されている全てを含むオブジェクト( ReduxState
)が上から(Index.tsxで書いたProviderから)渡ってきます。そのうち、「counterという状態( CounterState
)だけをCounterコンポーネントに渡すよ」という記述をしています。
次に②のdispatchですが、reduxではstateの状態を書き換えたい時にこの関数を呼びます。
ここでは、Viewに状態を持たせたくないという考えのもと、dispatchするロジックを隠蔽した ActionDispatcher
というオブジェクトを生成して、それをCounterコンポーネントに渡しています。
中身は見たら分かるのですが、Counterコンポーネントからイベントがあれば関数を呼んで、中でActionの生成とそれをreducerへとdispatchしています。ちなみに、今後サーバーとの通信などの副作用はここで行います。(reduxでは外部への副作用をできる箇所を厳密に制限しています。)
ちなみに、非同期処理をする場合によく名前の出てくる「ミドルウェア」に関してですが、必要さを感じるまでは使わなくていいと思います。僕は未だに使っていません。
この仕掛けによって、Counterコンポーネントでは value
とactions
だけを知っていれば良くなりreduxへの依存はしなくなるため、テストしやすくなります。
Counter.tsx
import * as React from "react";
import {CounterState} from "./module";
import {ActionDispatcher} from "./Container";
interface Props {
value: CounterState;
actions: ActionDispatcher;
}
export class Counter extends React.Component<Props, {}> {
render() {
return (
<div>
<p>score: {this.props.value.num}</p>
<button onClick={() => this.props.actions.increment(3)}>Increment 3</button>
<button onClick={() => this.props.actions.decrement(2)}>Decrement 2</button>
</div>
)
}
}
今回は最小構成なので1コンポーネントです。上述の通りreduxに一切依存しないように書けています。
ここでは、storeからvalueが渡ってくるので、それを表示しています。
イベントの発火は、親から渡ってきたactionsのメソッドを使います。
これによって、このコンポーネントは上から渡されたもの以外の状態を持たなくなり、テストしやすくなります。
(※ここで実はContainerとCounterが循環参照してしまっているのですが、型情報のみなので実挙動的には一応問題ないです。気にする場合はActionDispatcherを別ファイルに切り出しましょう。)
reduxでは、stateを変化させるにはActionをDispatchする必要がありますが、ここ(View層)では気にする必要がありません。上から流れてくるメソッドの方で勝手に生成と発火を行うためです。これによりView層がシンプルになりテストが容易になります。
module.ts(ActionCreator/Reducer)
reduxではconstantsとReducerとAction Creatorなどの登場人物がいるのですが、Ducksでは一つにまとめて依存の見通しを良くしています。
この記事では、大きくActionCreatorとReducerに分けて説明しましょう。
まずはActionCreatorの方です
// ActionCreator
import {Action} from 'redux'
enum ActionNames {
INC = 'counter/increment',
DEC = 'counter/decrement',
}
interface IncrementAction extends Action {
type: ActionNames.INC
plusAmount: number
}
export const incrementAmount = (amount: number): IncrementAction => ({
type: ActionNames.INC,
plusAmount: amount
})
interface DecrementAction extends Action {
type: ActionNames.DEC
minusAmount: number
}
export const decrementAmount = (amount: number): DecrementAction => ({
type: ActionNames.DEC,
minusAmount: amount
})
それぞれのActionは識別用のtype(一意の文字列)プロパティを持ち、 incrementAmount
などのメソッドがそのActionを生成しているのがわかるでしょうか?
(記述量が多く見えるのは型安全にしたいためです。すこし抵抗があるかもしれませんが、命名や定義を見ればそれぞれを理解するのは難しくないと思います。)
module.ts(reducer)
//reducer
export interface CounterState {
num: number
}
export type CounterActions = IncrementAction | DecrementAction
const initialState:CounterState = {num: 0};
export default function reducer(state: CounterState = initialState, action: CounterActions): CounterState {
switch (action.type) {
case ActionNames.INC:
return {num: state.num + action.plusAmount}
case ActionNames.DEC:
return {num: state.num - action.minusAmount}
default:
return state
}
}
ここではreduxのreducerを定義しています。
reducerは実質一つの大きな関数(副作用が無いという意味です。)で、そのシグニチャはreducer(<現在のstate>, <発火されたAction>): <変更後のState>
という形になります。
ここでは、 <現在のstate> = CounterState
、 <発火されたAction> = CounterActions
というのが分かるでしょうか?
reducerとしてやっていることは、Actionが流れてきたらそのtypeを見て、stateを新しいstateに変換する、と言った形です。
また、初期状態を与える必要があるので initialState
を組み込んでいます。ES6の記法ですね。
Buildしてみる
npm run build
してからindex.htmlを開くと、IncrementボタンとDecrementボタンが見えると思います。挙動は見たまんまで、数字が加減されます。
まとめ
Redux、シンプルに書けて良い感じですね。
続いて、テストと非同期処理についてまとめてみようと思います。