More than 1 year has passed since last update.

本記事はSupership株式会社 Advent Calendar 2016の7日目の記事になります。

株式会社Socket@notsunohito です。
株式会社SocketはSupership株式会社と同じSyn.グループのメンバーであり
Web接客と呼ばれるサービスのひとつであるFlipdeskを展開しています。

今回はFlipdeskの管理画面の一部をReact Reduxで書き直したときに
validationを実装するのに利用したライブラリreselect
について本日7日目と8日目を2回にわけて書きます。

TL;DR

reselectでvalidationを作ってみたら割りと良かった。

本記事で扱うサンプル全体のソースコードは
notsunohito/reselect-validation-exampleで、
実際の動作はここで確認できます。

なぜ管理画面の一部をReact Reduxで書き直したのか

FlipdeskはRailsベースのWebアプリのため管理画面はjQueryとjQueryUIで実装されていました。
リリースから数年を経て管理画面のクライアントサイドのコードベースは技術的な負債で溢れていました。
また、実際に技術的負債のため複雑なvalidationを追加するという要望を実現することが難しい
という問題にあたりReact Reduxで書き直すことに決めました。

なぜreselectを使ったのか

React Reduxにおいてvalidationをどう行うかの実装方針について調べてみても
スタンダードなものが見つけられなかっためreselectで自前で実装することに決めました。

reselectとは

Simple “selector” library for Redux
・Selectors can compute derived data, allowing Redux to store the minimal possible state.
・Selectors are efficient. A selector is not recomputed unless one of its arguments change.
・Selectors are composable. They can be used as input to other selectors.

とある通りreselectのselectorを用いると↓のようなことができます。

  • stateから導出可能なdataを計算する
  • パフォーマスのためにstate上のselectorが依存するツリーの変更時にのみ再計算を行う
  • selectorは別のselectorの引数として合成し再利用することができる

なぜvalidationにreselect?

validationとはstateのある状態がvalidな状態なのかどうかを計算することに他なりません。

Flipdeskの今回の事例ではstateのあるプロパティのサブツリー配下のすべてプロパティを
参照して個々のプロパティがvalidなのかどうかを個別に判定するというような複雑なvalidationを実現する必要がありました。

reselectを使えば↓のようなことが期待できそうだという見込みのもとに使いましたが
結果想定していた期待はすべて満たしてくれました。

  • validationロジックをReactreduxどちらにも依存せず実装できる
  • validationロジックの単体テストを容易に行える
  • validationの仕様変更が容易に行える

本投稿では

reselectで実現するシンプルなvalidationのサンプルを
redux + reselectで実装しReactでその結果を表示してみます。

コンポーネントなしでバリデーションを実装してみる

考え方

以下の構造のstateを持つアプリケーションを考えます。

例.

{
    name: '株式会社Socket',
    zipCode: '107-0062',
    address: '東京都港区南青山',
}

このstateに対して以下のようなdataを返すselectorを考えます。

{
    summary: OK, // => すべてのバリデーション結果がOKのときOK
    name: OK,
    zipCode: OK,
    address: OK
}

ここで登場するOKはstateの各プロパティをvalidationした結果を表します。その型をValidationStatusと呼ぶことにします。
OKの他に複数のERRORを定義し、ERROR時にはどういう理由でエラーなのかを説明するmessageを設定します。

constants/validationStatus.js

constants/validationStatus.js
const OK = { type:'OK', hasMessage: false }
const ERROR= { type: 'ERROR', hasMessage: false }
const ERROR_REQUIRED = { type:'ERROR', hasMessage: true, message: 'Required' }
const ERROR_INVALID_ZIP_CODE = { type:'ERROR', hasMessage: true, message: 'ZipCode must satisfy /^\d{3}-?\d{4}$/' }

考え方としてはstateの各プロパティをValidationStatusに変換して返す関数を定義する形になります。

selectorのエントリポイントを作成する

初めに、すべてのselectorのエントリポイントとなるrootSelectorを以下のように作ります。

*1 なお、ここでは実際にvalidationを行う関数を記述する
モジュールvalidatorは別ファイルに定義しvalidatorとしてrequireしています。

selectors/index.js

selectors/index.js
const {
    createSelector,
    createStructuredSelector
} = require('reselect');
const validator = require('./validator'); // *1
const {validationStatus} = require('../constants');
const {OK, ERROR, isOK} = validationStatus;


const nameSelector = createSelector( // *2
    (state)=> state.name,
    (name)=> validator.name(name)
)

const zipCodeSelector = createSelector(
    (state)=> state.zipCode,
    (zipCode)=> validator.zipCode(zipCode)
)

const addressSelector = createSelector(
    (state)=> state.address,
    (address)=> validator.address(address)
)

// name, zipCode, addressすべてOKならOK
const summarySelector = createSelector(
    nameSelector, // *3
    zipCodeSelector,
    addressSelector,
    (name, zipCode, address)=> [name, zipCode, address].every((status)=> isOK(status)) ? OK : ERROR
)

// どのプロパティにどのselectorを対応させるかをここで設定
const rootSelector = createStructuredSelector({ // *4
    summary: summarySelector,
    name: nameSelector,
    zipCode: zipCodeSelector,
    address: addressSelector
})


module.exports = rootSelector;

*2 createSelectorが実際にselectorを作るためのreselectのapiになります。

  • 第一引数がstateのどのプロパティに依存するかを決める関数を取ります。
  • 第二以降の引数ではその依存するプロパティから計算した値を返す関数を取ります。

*3 すでに定義済みのselectorを依存するプロパティを決定する関数として再利用することもできます。
*4 createStructuredSelectorstateのどのプロパティにどのselectorを割り当てるかを設定します。ReduxでいうcombineReducersのイメージに近いです。

validationの本体を書く

validationの本体となるvalidatorを実装してみます。
stateの各プロパティの値をValidationStatusに変換する関数を書きます。
(今回の例ではあえてシンプルな仕様のvalidationにしています。

selectors/validator.js

selectors/validator.js
const {validationStatus} = require('../constants')
const {OK, ERROR_REQUIRED, ERROR_INVALID_ZIP_CODE} = validationStatus


function name(name) {
    if(name === '') return ERROR_REQUIRED
    return OK
}

function zipCode(zipCode) {
    if(zipCode === '') return ERROR_REQUIRED
    // 日本の郵便番号の形式じゃないとき
    if(!/^\d{3}-?\d{4}$/.test(zipCode)) return ERROR_INVALID_ZIP_CODE
    return OK
}

function address(address) {
    if(address === '') return ERROR_REQUIRED
    return OK
}


module.exports = {
    name,
    zipCode,
    address
}

spec

rootSelectorのspecを書く

rootSelectorは↓のsubjectとして
実際に使われている通りrootSelector(state)のようにして使います。

spec/selectors/rootSelector.js

spec/selectors/rootSelector.js
const {expect} = require('chai')
const rootSelector = require('../../selectors/')
const {validationStatus} = require('../../constants')
const {OK, ERROR} = validationStatus


describe('rootSelector', ()=> {
    let subject = rootSelector
    let state

    beforeEach(()=> {
        state = { name: '株式会社Socket', zipCode: '107-0062', address: '東京都港区南青山'}
    })

    it('name, zipCode, address を validaitonStatus に変換する', ()=> {
        const {name, zipCode, address} = subject(state)
        expect(name).to.equal(OK)
        expect(zipCode).to.equal(OK)
        expect(address).to.equal(OK)
    })

    describe('summary', ()=> {
        context('name, zipCode, address が すべてOK のとき', ()=> {
            it('OK', ()=> {
                const {summary} = subject(state)
                expect(summary).to.equal(OK)
            })
        })

        context('name, zipCode, address のうちひとつでも Error があるとき', ()=> {
            beforeEach(()=> {
                state.name = ''
            })

            it('OK', ()=> {
                const {summary} = subject(state)
                expect(summary).to.equal(ERROR)
            })
        })
    })

})

validatorのspecを書く

validatorをコンポーネントに依存しない純粋な関数として抜き出せたので
以下のようにシンプルにテストを書くことができます。

spec/selectors/validator.js

spec/selectors/validator.js
const {expect} = require('chai')
const validator = require('../../selectors/validator')
const {validationStatus} = require('../../constants')
const {OK, ERROR_REQUIRED, ERROR_INVALID_ZIP_CODE} = validationStatus

describe('validator', ()=> {

    describe('name', ()=> {
        let subject = validator.name

        describe('引数', ()=> {
            it('値が空 だと ERROR_REQUIRED', ()=>{
                expect(subject('')).to.equal(ERROR_REQUIRED)
            })

            it('値があれば OK', ()=> {
                expect(subject('株式会社Socket')).to.equal(OK)
            })
        })
    })

    describe('zipCode', ()=> {
        let subject = validator.zipCode

        describe('引数', ()=> {
            it('値が空 だと ERROR_REQUIRED', ()=>{
                expect(subject('')).to.equal(ERROR_REQUIRED)
            })

            it('/^\d{3}-?\d{4}$/ でないと ERROR_INVALID_ZIP_CODE', ()=> {
                expect(subject('0123-456')).to.equal(ERROR_INVALID_ZIP_CODE)
                expect(subject('01-23456')).to.equal(ERROR_INVALID_ZIP_CODE)
                expect(subject('012345')).to.equal(ERROR_INVALID_ZIP_CODE)
                expect(subject('01234567')).to.equal(ERROR_INVALID_ZIP_CODE)
                expect(subject('012345a')).to.equal(ERROR_INVALID_ZIP_CODE)
            })

            it('/^\d{3}-?\d{4}$/ だと OK', ()=> {
                expect(subject('012-3456')).to.equal(OK)
                expect(subject('0123456')).to.equal(OK)
            })
        })
    })

    describe('address', ()=> {
        let subject = validator.address

        describe('引数', ()=> {
            it('値が空だと ERROR_REQUIRED', ()=>{
                expect(subject('')).to.equal(ERROR_REQUIRED)
            })

            it('値があれば OK', ()=> {
                expect(subject('東京都港区南青山')).to.equal(OK)
            })
        })
    })

})

specを実行する

$ npm test

> reselect-validation-example@1.0.0 test /Users/notsu/projects/reselect-validation-example
> mocha --require babel-register src/spec/index.js



  rootSelector
    ✓ name, zipCode, address を validaitonStatus に変換する
    summary
      name, zipCode, address が すべてOK のとき
        ✓ OK
      name, zipCode, address のうちひとつでも ERROR があるとき
        ✓ ERROR

  validator
    name
      引数
        ✓ 値が空 だと ERROR_REQUIRED
        ✓ 値があれば OK
    zipCode
      引数
        ✓ 値が空 だと ERROR_REQUIRED
        ✓ /^d{3}-?d{4}$/ でないと ERROR_INVALID_ZIP_CODE
        ✓ /^d{3}-?d{4}$/ だと OK
    address
      引数
        ✓ 値が空だと ERROR_REQUIRED
        ✓ 値があれば OK


  10 passing (215ms)

よさそうです。

7日目のまとめ

reselectを使うと

  • validationをReact, Reduxに依存しない単なる(state)=> ValidationStatusな関数としてシンプルに扱える。
  • シンプルなのでテストも簡単に書ける

また、コンポーネントからvalidationロジックを追い出せるのでReactでコンポーネントを
Stateless Functional Component化することを後押ししてくれます。

続き

残るはstateを基に値化したValidationStatusをコンポーネントで表示するだけですが長くなったので
続きは 明日Supership株式会社 Advent Calendar 2016の8日目として引き続き私 @notsunohito が公開させて頂きます。