Enigmo Advent Calendar 2018の4日目の記事です。
この記事の目的
Enigmoが運営しているBUYMAでは古代から運用しているjQueryの他に、2016年頃から一部ページのフロントエンドをReact/Reduxで構築しています。
私自身もEnigmoに入社してからの約三年間でReact/Reduxアプリケーションの開発に多数携わってきましたので、そこで培った知見を共有したいと思います。
React/Reduxの利点
まずはじめに、ReactとReduxを使うメリットを再確認しておきたいと思います。
それぞれのメリットをしっかりと認識しておくことで、実装する際どう書くか迷ってしまった場合などにそのメリットを最大限活かす選択をすることができます。
Reactの利点
- コンポーネント化が容易で再利用性が高い
- 状態をDOMから分離できる(Stateless)
- Reduxのような外部ライブラリを併用するとコンポーネント自体からも分離できる
- (props) => DOM Tree という関数のように扱える(同じinputなら常に同じoutputを得られる)
- Propsとしてイベントハンドラを渡していくアーキテクチャなので、ロジックをまとめて疎結合にしやすい
Reduxの利点
- Reducer、ActionCreator、Componentといった利用側が書くべきモジュールはすべて純粋な関数で書ける
- ほとんど関数の集合だけでアプリケーションが完成する
- 副作用のある処理はすべて後述するMiddleware層に持っていける
- 上記の理由からテストが非常に書きやすい
React/Reduxはこう書く!!
では実際に私がReact/Reduxアプリケーションを実装する際指標としているプラクティスを解説していきたいと思います。
Container Componentsの分割
Container Components
とは Redux Store
と connect
しているコンポーネントのことです。
詳細はReduxの公式Docをご確認ください。
Container Componentsを適切に分割することでコードの見通しを良くします。
Containerの中にContainerが存在する、 Container in Container も許容します。
分割されていない例
import { connect } from 'react-redux'
import * as React from 'react'
import ComponentA from '../components/ComponentA'
import ComponentB from '../components/ComponentB'
import * as actions from '../actions'
class BadContainer extends React.Component {
componentDidMount() {
this.props.fetch()
}
render() {
return (
<div>
<ComponentA
name={this.props.nameA}
handler1={this.props.handlerA1}
handler2={this.props.handlerA2}
/>
<ComponentB
name={this.props.nameB}
handler1={this.props.handlerB1}
handler2={this.props.handlerB2}
/>
</div>
)
}
}
const mapStateToProps = state => {
return {
nameA: state.a.name,
nameB: state.b.name
}
}
const mapDispatchToProps = dispatch => {
return {
handlerA1() {
dispatch(actions.actionA1())
},
handlerA2() {
dispatch(actions.actionA2())
},
handlerB1() {
dispatch(actions.actionA1())
},
handlerB2() {
dispatch(actions.actionA2())
},
fetch() {
dispatch(actions.fetchData())
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(BadContainer)
この例では一つのContainerで2つのコンポーネント、ComponentAとComponentBを表示しようとしています。
今回の例では2つだけですが、今後3つ、4つと表示するコンポーネントが増えていくとその分必要なハンドラーやプロパティが増えていくため、 mapStateToProps
と mapDispatchToProps
が肥大化してしまい見通しが悪くなります。
分割されている例
// GoodContainer.jsx
import { connect } from 'react-redux'
import * as React from 'react'
import ContainerA from './ContainerA'
import ContainerB from './ContainerB'
import * as actions from '../actions'
class GoodContainer extends React.Component {
componentDidMount() {
this.props.fetch()
}
render() {
return (
<div>
<ContainerA />
<ContainerB />
</div>
)
}
}
const mapDispatchToProps = dispatch => {
return {
fetch() {
dispatch(actions.fetchData())
}
}
}
export default connect(()=> { return {} }, mapDispatchToProps)(GoodContainer)
// ContainerA.jsx
import { connect } from 'react-redux'
import * as React from 'react'
import ComponentA from '../components/ComponentA'
import * as actions from '../actions'
const mapStateToProps = state => {
return {
name: state.a.name,
}
}
const mapDispatchToProps = dispatch => {
return {
handler1() {
dispatch(actions.actionA1())
},
handler2() {
dispatch(actions.actionA2())
},
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ComponentA)
// ContainerB.jsx
import { connect } from 'react-redux'
import * as React from 'react'
import ComponentB from '../components/ComponentB'
import * as actions from '../actions'
const mapStateToProps = state => {
return {
name: state.b.name,
}
}
const mapDispatchToProps = dispatch => {
return {
handler1() {
dispatch(actions.actionB1())
},
handler2() {
dispatch(actions.actionB2())
},
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ComponentB)
分割されている例では、もともと componentDidMount
でデータフェッチしていた箇所だけを元のContainerに残し、 ComponentA
と ComponentB
に関するプロパティとハンドラーをそれぞれ ContainerA
と ContainerB
に分割しています。
こうすることでコードの肥大化を防ぎ見通しがかなり良くなったと思います。
ただし、全てのコンポーネントを connected
にするとやりすぎなので適切な単位で分割する必要がありますのでご注意ください。
例えばECサイトの場合、商品名、商品コメント、配送方法、のようなおおざっぱな単位で分割していき、それでもコードの見通しが悪いと感じたら更に分割するというような感じで実装しています。
またContainer Componentsのテストの仕方については以前書いた記事がありますので読んで頂けると幸いです。
https://qiita.com/hiyamamoto/items/281709cc2a98268fb6c2
Presentational Componentsの分割
前項に続きコンポーネントの分割についてです。
Presentational Components
についての詳細はReduxの公式Docをご確認ください。
Presentational Components
を分割する理由としては前項と同様に見通しを良くするため、テストのしやすさのためです。
また、Reactのメリットにも書いてある再利用性を高めるという利点もあります。
まずは分割されていない例を見ていきましょう。
適切に分割されていない例
function Page(props) {
return (
<div className="container">
<div className="header">
<h1>{props.headerTitle}</h1>
</div>>
<div className="body">
<button onClick={props.onClickForward}>進む</button>
<button onClick={props.onClickBackward}>戻る</button>
<p>{props.value}</p>
</div>
<div className="footer">
<a href="/path/1">Link1</a>
<a href="/path/2">Link2</a>
</div>
</div>
)
}
こちらの例では複数の div
、 button
、 a
要素が出てきています。
そんなに大きなコンポーネントではありませんが既にちょっと見通しが悪くなっていると思います。
また、このコンポーネントのテストは以下になります。
※ コンポーネントのテストは enzyme というライブラリの使用を前提としています
describe('<Page />', () => {
const wrapper = shallow(<Page {...props} />)
it('render 進むButton', () => {
// button が複数出てくるのでうまく特定出来ない
expect(wrapper.find('button').fisrt().contains('進む')).to.be.true
})
})
このように複数の同一要素がある場合は wrapper.find('button').first()
のように表示順を意識してコンポーネントの取得をする必要が出てきてしまい、ただ表示順が変わっただけでテストが壊れてしまいます。
では分割されている例を見てみましょう。
適切に分割されている例
function Page(props) {
return (
<Container>
<Header title={props.headerTitle} />
<Body>
<ForwardButton onClick={props.onClickForward} />
<BackwardButton onClick={props.onClickBackward} />
<BodyContent value={props.value} />
</Body>
<Footer>
<Link1 />
<Link2 />
</Footer>
</Container>
)
}
分割されている例では分割されていない例で見られた div
などが一切出てきていないため見通しが良くなっているのがわかると思います。
また、それぞれのコンポーネントの意味がコンポーネント名からひと目で分かるようになっています。
このコンポーネントのテストはこちらです。
describe('<Page />', () => {
const wrapper = shallow(<Page {...props} />)
it('render 進むButton', () => {
// ForwardButton というコンポーネントがあるかテストするだけで良い
expect(wrapper.find(ForwardButton)).to.have.length(1)
})
})
進むボタンがコンポーネント化されたため、戻るボタンとの表示順を気にする必要がなくなり、デザインの都合で表示順が変わったとしてもテストが壊れることがなくなりました。
ComponentをStatelessに保つ
Reactの利点として状態をDOMから分離できるということを前述しましたが、コンポーネントからも状態を分離することで本来のコンポーネントが持つViewとしての役割だけに専念させることができます。
Reactの基本として state
というもので状態を管理することができますが、その state
そのものを持たないコンポーネントを Stateless Functional Component
と呼びます。
React/Reduxアプリケーションでは state
は基本的に redux store
で管理し、コンポーネントでは state
を使わないようにすることで状態管理を一元化することができます。
ただし、ボタンクリック時にモーダルを表示したり、フォーカスしたときに値を加工するだけなど、そのコンポーネント内で完結するような state
を持つことはさほど問題にはならず、全ての状態を redux store
で管理しなければいけないというわけではないと個人的には考えています。
あるコンポーネントの状態を別のツリーのコンポーネントで参照したい場合や、ドメインロジックに関わる状態は redux store
で管理し、コンポーネント内部で完結する状態はそのコンポーネントの state
として管理するように柔軟に設計するのがベターです。
Stateless Functional Component の書き方
state
や lifecycleメソッド
を持たない場合は class
シンタックスではなく、関数を使います。
コンポーネント名として関数名が使われるため、 arrow function
より通常の関数として書くとよいです。
function FooComponent(props) {
return (
<div>
<p>名前: {props.name}</p>
<p>年齢: {props.age}</p>
</div>
)
}
利点
- ただの関数なのでテストしやすい
- stateを持ってないことが一発でわかる
Reducerの分割
Reduxでは combineReducers
という関数を使って通常Reducerを分割すると思います。
Reduxの公式DocではReducerを分割する際、コンポーネントのレンダリングツリーで分割するのではなくドメインデータごとに分割することを推奨しています。
また、 DomainState、 AppState、 UIState という3つのStateに分割することが提案されています。
DomainState
例えば、商品や取引などのドメイン特有のstateのことです。
前述したレンダリングツリーごとの分割とドメインデータごとの分割の比較をしてみます。
レンダリングツリーごとの分割
レンダリングツリーごとの分割は簡単に言うと画面ごとの分割ということです。
下記は 新規画面
、 編集画面
、 一覧画面
といった画面ごとに分割している例です。
reducers
├── newReducer.js
├── editReducer.js
└── listReducer.js
ドメインデータごとの分割
変わってドメインデータごとの分割では、商品
、 配送方法
、 ブランド
などのドメインデータで分割しています。
reducers
├── productReducer.js
├── shipphingReducer.js
└── brandReducer.js
AppState
アプリケーション全体の state
はドメインデータ用のReducerとは別のReducerとして用意すると、見通しが良くなります。
例えば、データをローディング中かどうかを管理する isLoading
などの state
はこちらに含めます。
UIState
モーダルの表示状態などのUI特有の state
も別のReducerで管理します。
ただし、前述のようにコンポーネントの state
として持つことも多いです。
stateはPOJO(Plain Old JavaScript Object)
state
は基本的に immutable(不変) object
として扱います。
下記のようなコードはNGです。
const defaultState = {
foo: 'foo',
bar: 'bar'
}
const reducer(state = defaultState, action) => {
if (action.type === 'Foo Action') {
state.foo = action.payload // fooだけ変更したい
return state
}
}
immutable.js を使えばかんたんに不変オブジェクトを生成できます。
import { Map } from 'immutable'
const defaultState = Map({
foo: 'foo',
bar: 'bar'
})
const reducer(state = defaultState, action) => {
if (action.type === 'Foo Action') {
return state.set('foo', action.payload) // fooだけ変更したい
}
}
上記の Map.prototype.set
は常に新しい Map
のインスタンスを返します。
ただし、 Map
は普通のオブジェクトのようにプロパティにアクセスできないので、コンポーネント内でアクセスする際には state.get('foo')
のようにする必要があります。
また、APIなどから取得してきたJSONを毎回 Map
オブジェクトに変換する必要があるため結構面倒です。
それ、ES2015+でできるよ
const defaultState = {
foo: 'foo',
bar: 'bar'
}
const reducer(state = defaultState, action) => {
if (action.type === 'Foo Action') {
return {
...state,
foo: action.payload // fooだけ変更できる!
}
}
}
state
は色々な場所(コンポーネント、APIクライアントなど)でアクセスするので、「immutableオブジェクトになってるか?」といちいち判定するのは面倒です。 常に POJO
にしておくことでその手間を減らすことができます。
Reducerからドメインロジックを分離したい場合
ドメインデータ用のReducerにドメインロジックを書いてしまうとコードがどんどん肥大化していってしまうため、下記のようにドメインデータを models
ディレクトリ配下に書きたくなると思います。
import MyClass from '../models/MyClass'
const defaultState = new MyClass({
foo: 'foo',
bar: 'bar'
})
const reducer(state = defaultState, action) => {
if (action.type === 'Foo Action') {
// ドメインロジックはドメインクラスに委譲
return state.changeFoo(action.payload)
}
}
このようにしたい場合は models
配下からクラスではなく関数をエクスポートすることで代替できます。
import { changeFoo } from '../models/MyDomainModel'
const defaultState = new MyClass({
foo: 'foo',
bar: 'bar'
})
const reducer(state = defaultState, action) => {
if (action.type === 'Foo Action') {
// ドメインロジックはドメインモデルの関数に委譲
return changeFoo(state, action.payload)
}
}
export const changeFoo = (model, value) => {
// 複雑な処理
return {
...model,
foo
}
}
他にも immutable.jsのRecordを使う方法などがありますが、前述の通り変換の手間などを考えると個人的にはあまりおすすめしません。
FSA(Flux Standard Action)を使う
Flux Standard Action とは
actionの型が実装者によってまちまちになると読みづらいし不便だから標準化しましょうね、という話です。
割と界隈ではデファクトになってる気がします。
非同期処理の成功、失敗ごとに LOAD_SUCCESS
、 LOAD_FAILURE
のようにactionを分けるのではなく LOAD_FINISH
のように一つにまとめることが出来ます。
{
type: 'LOAD_FINISH',
payload: {
id: 1,
text: 'Do something.'
},
meta: {
... // metadata
}
}
{
type: 'LOAD_FINISH',
payload: new Error(),
error: true
}
アクションの型が統一されて書く方も読む方も負荷が減るのでおすすめです。
非同期処理
reduxの登場人物(component, reducer, actionCreator)は純粋な関数の集まりなので副作用のある処理を書く場所がありません。
一般的に副作用のある処理は Middleware層
を利用します。
非同期処理をする為のMiddlewareとしては redux-thunk と redux-saga が有名です。
redux-thunk vs redux-saga
redux-thunk
Pros
- APIがかんたんで学習コストがほぼ0
- サッと導入してサッと書ける
Cons
- actionCreatorにロジックが入り込む
- actionCreatorから純粋さがなくなる
- そのため actionCreatorのテストが複雑になる
redux-saga
Pros
- 副作用のある処理が完全に分離できる
- APIが豊富にあり並列化したりスロットルしたり色々出来る
- 高テスタビリティ
Cons
- 学習コストが高い
- Generator関数はとっつきにくい
- APIが多い
小さめのアプリケーションでは redux-thunk
、大きくて複雑なアプリケーションでは redux-saga
、 というように使い分けるといいでしょう。
まとめ
どのフレームワークにも言えることですが、見通しがよくメンテしやすいアプリケーションを書くためにはコードを適切な単位で分割することが非常に重要です。
React/Reduxアプリケーションではコンポーネントを分割し再利用性を高めたり、状態を適切に分割することで、それぞれのメリットを最大限に活かせると思います。
Reduxの公式Docでは今回書いたReduxアプリケーションを設計する上での考え方が詳細に書かれていて非常に参考になるのでぜひご一読ください。