56
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

EnigmoAdvent Calendar 2018

Day 4

React/Reduxを約三年間書き続けてきたので知見を共有します

Last updated at Posted at 2018-12-03

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 Storeconnect しているコンポーネントのことです。
詳細はReduxの公式Docをご確認ください。

Container Componentsを適切に分割することでコードの見通しを良くします。
Containerの中にContainerが存在する、 Container in Container も許容します。

分割されていない例

BadContainer.jsx
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つと表示するコンポーネントが増えていくとその分必要なハンドラーやプロパティが増えていくため、 mapStateToPropsmapDispatchToProps が肥大化してしまい見通しが悪くなります。

分割されている例

// 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に残し、 ComponentAComponentB に関するプロパティとハンドラーをそれぞれ ContainerAContainerB に分割しています。
こうすることでコードの肥大化を防ぎ見通しがかなり良くなったと思います。
ただし、全てのコンポーネントを connected にするとやりすぎなので適切な単位で分割する必要がありますのでご注意ください。
例えばECサイトの場合、商品名商品コメント配送方法、のようなおおざっぱな単位で分割していき、それでもコードの見通しが悪いと感じたら更に分割するというような感じで実装しています。

またContainer Componentsのテストの仕方については以前書いた記事がありますので読んで頂けると幸いです。
https://qiita.com/hiyamamoto/items/281709cc2a98268fb6c2

Presentational Componentsの分割

前項に続きコンポーネントの分割についてです。
Presentational Components についての詳細はReduxの公式Docをご確認ください。

Presentational Components を分割する理由としては前項と同様に見通しを良くするため、テストのしやすさのためです。
また、Reactのメリットにも書いてある再利用性を高めるという利点もあります。

まずは分割されていない例を見ていきましょう。

適切に分割されていない例

bad.jsx
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>
  )
}

こちらの例では複数の divbuttona 要素が出てきています。
そんなに大きなコンポーネントではありませんが既にちょっと見通しが悪くなっていると思います。

また、このコンポーネントのテストは以下になります。
コンポーネントのテストは enzyme というライブラリの使用を前提としています

bad_spec.jsx
describe('<Page />', () => {
  const wrapper = shallow(<Page {...props} />)

  it('render 進むButton', () => {
    // button が複数出てくるのでうまく特定出来ない
    expect(wrapper.find('button').fisrt().contains('進む')).to.be.true
  })
})

このように複数の同一要素がある場合は wrapper.find('button').first() のように表示順を意識してコンポーネントの取得をする必要が出てきてしまい、ただ表示順が変わっただけでテストが壊れてしまいます。

では分割されている例を見てみましょう。

適切に分割されている例

good.jsx
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 などが一切出てきていないため見通しが良くなっているのがわかると思います。
また、それぞれのコンポーネントの意味がコンポーネント名からひと目で分かるようになっています。

このコンポーネントのテストはこちらです。

good_spec.jsx

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 の書き方

statelifecycleメソッド を持たない場合は 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を分割する際、コンポーネントのレンダリングツリーで分割するのではなくドメインデータごとに分割することを推奨しています。
また、 DomainStateAppStateUIState という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 配下からクラスではなく関数をエクスポートすることで代替できます。

reducer.js
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)
  }
}
MyDomainModel.js
export const changeFoo = (model, value) => {
  // 複雑な処理
  return {
    ...model,
    foo
  }
}

他にも immutable.jsのRecordを使う方法などがありますが、前述の通り変換の手間などを考えると個人的にはあまりおすすめしません。

FSA(Flux Standard Action)を使う

Flux Standard Action とは

actionの型が実装者によってまちまちになると読みづらいし不便だから標準化しましょうね、という話です。
割と界隈ではデファクトになってる気がします。

非同期処理の成功、失敗ごとに LOAD_SUCCESSLOAD_FAILURE のようにactionを分けるのではなく LOAD_FINISH のように一つにまとめることが出来ます。

success.js
{
  type: 'LOAD_FINISH',
  payload: {
    id: 1,
    text: 'Do something.'  
  },
  meta: {
    ... // metadata
  }
}
failure.js
{
  type: 'LOAD_FINISH',
  payload: new Error(),
  error: true
}

アクションの型が統一されて書く方も読む方も負荷が減るのでおすすめです。

非同期処理

reduxの登場人物(component, reducer, actionCreator)は純粋な関数の集まりなので副作用のある処理を書く場所がありません。
一般的に副作用のある処理は Middleware層 を利用します。

非同期処理をする為のMiddlewareとしては redux-thunkredux-saga が有名です。

redux-thunk vs redux-saga

redux-thunk

Pros

  • APIがかんたんで学習コストがほぼ0
  • サッと導入してサッと書ける

Cons

  • actionCreatorにロジックが入り込む
  • actionCreatorから純粋さがなくなる
  • そのため actionCreatorのテストが複雑になる

redux-saga

Pros

Cons

  • 学習コストが高い
    • Generator関数はとっつきにくい
    • APIが多い

小さめのアプリケーションでは redux-thunk、大きくて複雑なアプリケーションでは redux-saga、 というように使い分けるといいでしょう。

まとめ

どのフレームワークにも言えることですが、見通しがよくメンテしやすいアプリケーションを書くためにはコードを適切な単位で分割することが非常に重要です。
React/Reduxアプリケーションではコンポーネントを分割し再利用性を高めたり、状態を適切に分割することで、それぞれのメリットを最大限に活かせると思います。

Reduxの公式Docでは今回書いたReduxアプリケーションを設計する上での考え方が詳細に書かれていて非常に参考になるのでぜひご一読ください。

56
43
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?