React 復習メモ(その 2)

Redux と仲直りする

一昨日、Node.js 再入門の日記で Flux を試した。 今回はこの勢いで React な方向に歩を進めて Redux と仲直りしたいと思う。

tercel-s.hatenablog.jp

Flux ネタはいろいろな技術を組み合わせすぎてサンプルが繁雑になってしまったため、今回は React に焦点を絞る。

本日の題材

本日の題材の元ネタは、「WEB+DB PRESS Vol.92」にあったカウンタ的なプログラムである。

[+] ボタンを押すとカウントアップ、[-] ボタンを押すとカウントダウンするだけのものだ。

面白みはないが、今はきちんとした日本語の説明を書く余裕がないため、後からコードを読み返して思い出しやすいようサンプルはシンプルな方がよいだろう。

準備

前準備として、create-react-app で React のプロジェクトを作っておこう。

React 実装(ノーマル)

はじめに、Flux も Redux も使わず、 React のみでカウンタを書いてみる。

src/App.js
import React, { Component } from 'react'

class App extends Component {
  constructor(props) {
    super(props)
    this.state = { counter: 0 }
  }

  incrementCounter() {
    this.setState({ counter: this.state.counter + 1 })
  }

  decrementCounter() {
    this.setState({ counter: this.state.counter - 1 })
  }

  render() {
    return (
      <div>
        <div>{this.state.counter}</div>
        <button
          onClick={() => this.incrementCounter()}>+</button>
        <button
          onClick={() => this.decrementCounter()}>-</button>
      </div>
    )
  }
}

export default App;

Flux 適用版

上記のコードに対して Flux を適用してみた。 これはあくまで通過点というか、参考情報というか。

$ npm i --save flux
src/App.js
import React, { Component } from 'react'
import { Dispatcher } from 'flux'

// Store
const CounterStore = {
  counter: 0,
  onChange: null
}

// Action
const ActionType = {
  INCREMENT_COUNTER: 'INCREMENT_COUNTER',
  DECREMENT_COUNTER: 'DECREMENT_COUNTER'
}
const Actions = {
  incrementCounter: () => {
    AppDispatcher.dispatch({
      actionType: ActionType.INCREMENT_COUNTER,
      newValue: CounterStore.counter + 1
    })
  },
  decrementCounter: () => {
    AppDispatcher.dispatch({
      actionType: ActionType.DECREMENT_COUNTER,
      newValue: CounterStore.counter - 1
    })
  }
}

// Dispatcher
const AppDispatcher = new Dispatcher()
AppDispatcher.register(payload => {
  switch (payload.actionType) {
    case ActionType.INCREMENT_COUNTER:
    case ActionType.DECREMENT_COUNTER:
      CounterStore.counter = payload.newValue
      CounterStore.onChange()
      break
    default:
      break
  }
})

class App extends Component {
  constructor(props) {
    super(props)
    this.state = { counter: 0 }
    CounterStore.onChange = () => {
      this.setState({ counter: CounterStore.counter })
    }
  }

  render() {
    return (
      <div>
        <div>{this.state.counter}</div>
        <button
          onClick={() => Actions.incrementCounter()}>+</button>
        <button
          onClick={() => Actions.decrementCounter()}>-</button>
      </div>
    )
  }
}

export default App;

Redux 適用版(ノーマル)

ここからいよいよ Redux を適用する。

まずは混じりけのない Redux で先のコードをリファクタリングしてみる。

$ npm i --save redux react-redux

src/App.js

import React from 'react'
import { connect } from 'react-redux'

// Action Creators
function incrementCounter() { return { type: 'INCREMENT_COUNTER' }; }
function decrementCounter() { return { type: 'DECREMENT_COUNTER' }; }

// Reducer
export function counter(state = { counter: 0 }, action) {
    switch (action.type) {
        case 'INCREMENT_COUNTER':
            return { counter: state.counter + 1 };
        case 'DECREMENT_COUNTER':
            return { counter: state.counter - 1 };
        default:
            return state;
    }
    return state;
}

// Reactコンポーネント
let App = ({ counter, dispatch }) => (
    <div>
        <div>{counter}</div>
        <button onClick={() =>
            dispatch(incrementCounter())}>+</button>
        <button onClick={() =>
            dispatch(decrementCounter())}>-</button>
    </div>
);

// ReactとReduxの接続
function mapStateToProps(state) {
    return { counter: state.counter }
}
App = connect(mapStateToProps)(App)

export default App
src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App, { counter } from './App'
import { createStore } from 'redux'
import { Provider } from 'react-redux'

import './index.css'
import registerServiceWorker from './registerServiceWorker'

const store = createStore(counter)

ReactDOM.render(
  <Provider store={store}><App /></Provider>,
  document.getElementById('root')
);

registerServiceWorker();

Redux 適用版(Reducer 分割版)

Redux でコーディングしていると、どうしても Reducer はメタボ化するらしい。

そんな Reducer を一箇所に固めて書くとどうしてもモンスターコード化して保守しづらくなるので、Reducer は分割定義しておき、あとで combineReducers でひとまとめにするのがよい実装らしい。

ただし combineReducers はいくつかはまりどころがある。 一度わかってしまえばなんということもないが、たまに忘れるので参考になる Qiita を自分用に貼っておく。

qiita.com

なお、先ほどのコードを combineReducers 使用版に書き換えた例が以下。 べつに Reducer を複数に分けているわけではないため、あまり実用性はないか。

src/App.js
import React from 'react'
import { combineReducers } from 'redux';
import { connect } from 'react-redux'

// Action Creators
function incrementCounter() { return { type: 'INCREMENT_COUNTER' }; }
function decrementCounter() { return { type: 'DECREMENT_COUNTER' }; }

// Reducer
function counter(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT_COUNTER':
      return state + 1;
    case 'DECREMENT_COUNTER':
      return state - 1;
    default:
      return state;
  }
}

const reducer = combineReducers({ counter });
export { reducer }

// Reactコンポーネント
let App = ({ counter, dispatch }) => (
  <div>
    <div>{counter}</div>
    <button onClick={() =>
      dispatch(incrementCounter())}>+</button>
    <button onClick={() =>
      dispatch(decrementCounter())}>-</button>
  </div>
);

// ReactとReduxの接続
function mapStateToProps(state) {
  return { counter: state.counter }
}
App = connect(mapStateToProps)(App)

export default App
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux'
import App, { reducer } from './App';

import './index.css';
import registerServiceWorker from './registerServiceWorker';

const store = createStore(reducer)

ReactDOM.render(
  <Provider store={store}><App /></Provider>,
  document.getElementById('root')
);

registerServiceWorker();

Redux-Actions 適用版

最後に、Action (と Reducer)まわりの記述がラクになる Redux-Actions を試す。

無駄な switch 文が消えてスッキリするので、私はこちらの書き方が好みだ。

$ npm i --save redux-actions
src/App.js
import React from 'react'
import { connect } from 'react-redux'
import { createActions, handleActions } from 'redux-actions';

// Actions
const actions = createActions(
  'INCREMENT_COUNTER',
  'DECREMENT_COUNTER'
);

// Reducer
const counter = handleActions({
  [actions.incrementCounter]: (state, action) =>
    ({ counter: state.counter + 1 }),
  [actions.decrementCounter]: (state, action) =>
    ({ counter: state.counter - 1 })
}, { counter: 0 });  // 初期値
export { counter as reducer }

// Reactコンポーネント
let App = ({ counter, dispatch }) => (
  <div>
    <div>{counter}</div>
    <button onClick={() =>
      dispatch(actions.incrementCounter())}>+</button>
    <button onClick={() =>
      dispatch(actions.decrementCounter())}>-</button>
  </div>
);

// ReactとReduxの接続
function mapStateToProps(state) {
  return { counter: state.counter }
}
App = connect(mapStateToProps)(App)

export default App
Redux-Actions 非使用版との diff
diff --git a/src/App.js b/src/App.js
index 6d6ada4..4e7fd39 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,34 +1,30 @@
 import React from 'react'
-import { combineReducers } from 'redux';
 import { connect } from 'react-redux'
+import { createActions, handleActions } from 'redux-actions';
 
-// Action Creators
-function incrementCounter() { return { type: 'INCREMENT_COUNTER' }; }
-function decrementCounter() { return { type: 'DECREMENT_COUNTER' }; }
+// Actions
+const actions = createActions(
+  'INCREMENT_COUNTER',
+  'DECREMENT_COUNTER'
+);
 
 // Reducer
-function counter(state = 0, action) {
-  switch (action.type) {
-    case 'INCREMENT_COUNTER':
-      return state + 1;
-    case 'DECREMENT_COUNTER':
-      return state - 1;
-    default:
-      return state;
-  }
-}
-
-const reducer = combineReducers({ counter });
-export { reducer }
+const counter = handleActions({
+  [actions.incrementCounter]: (state, action) =>
+    ({ counter: state.counter + 1 }),
+  [actions.decrementCounter]: (state, action) =>
+    ({ counter: state.counter - 1 })
+}, { counter: 0 });  // 初期値
+export { counter as reducer }
 
 // Reactコンポーネント
 let App = ({ counter, dispatch }) => (
   <div>
     <div>{counter}</div>
     <button onClick={() =>
-      dispatch(incrementCounter())}>+</button>
+      dispatch(actions.incrementCounter())}>+</button>
     <button onClick={() =>
-      dispatch(decrementCounter())}>-</button>
+      dispatch(actions.decrementCounter())}>-</button>
   </div>
 );