人気のFluxフレームワークReduxをさわってみた

fluxフレームワーク、今は Redux が一番アツい様子なので触ってみた。

github.com

(追記 2015-10-03 18:15 APIや用語が大幅に変わったので更新した)

Redux の経緯

2015-05-30 に公開された "The Evolution of Flux Framework" という記事がある。

medium.com

Reduxはその PoC 的な実装だったんだけど、実用的ということでめっちゃ流行っているみたい。
Initial commit も 2015-05-30 だった。
Initial commit · rackt/redux@8bc1465 · GitHub

最近の flux フレームワークだと fluxibleflummox が人気だったけど、
flummoxのページには 4.0 will likely be the last major release. Use Redux instead. It's really great. なんて書いてある

reduxの特徴

以下の特徴をサッと紹介する。

  • 全ての state を redux オブジェクトが管理する
  • Store の更新ロジックや ActionCreator がステートレスな関数
  • hot reload や undo, redo が簡単に実装できる

僕も理解が怪しいので、詳しくは README.mdThe Evolution of Flux Framework を読んでくれ。

全ての state を store オブジェクトが管理する

Reduxでは、全ての状態を store オブジェクトに持たせ、コンポーネントでは store を呼び出して必要な情報を受け取る。

↓のコードでは、まず全ての Store を登録した store を作成し、CounterApp以下のコンポーネントから利用できるようにしている。

import { createStore } from 'redux';
import { Provider } from 'react-redux';
import * as stores from '../stores/index';

const store = createStore(stores);

export default class App {
  render() {
    return (
      <Provider store={store}>
        {() =>
          <CounterApp />
        }
      </Provider>
    );
  }
}

コンポーネント側では↓のようにして、state 内の必要な情報を受け取る。

import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';

@connect(state => ({
  counter: state.counter
}))
export default class CounterApp {
  render() {
    const { counter } = this.props;
    return (
      <Counter counter={counter} />
    );
  }
}

この時、CounterApp 下の全てのコンポーネント@connect を使って情報を取り出せる。

@connect で指定したデータは props に渡される。

Reducer が状態を持たない

Flux では、興味領域毎に Store が存在し、 Store が保持する状態の合成で世界を表現していた。
この場合、 Store 内の情報の更新ロジックは Store 自身が持っていた。

これに対し、Reduxでは、状態は全て storestate に保存する。
そして、state を更新するロジックを興味領域毎に分割する。
これを Reducer と呼ぶ。

Reducer は状態を持たず、「state を受け取って次の state を返す関数」である。
結果、 state を更新するロジックが常にステートレスに保たれる。

import { ADD_TODO, DELETE_TODO } from '../constants/ActionTypes';

const initialState = [{
  text: 'Use Redux',
  marked: false,
  id: 0
}];

export default function todos(state = initialState, action) {
  switch (action.type) {
  case ADD_TODO:
    return [{
      id: (state.length === 0) ? 0 : state[0].id + 1,
      marked: false,
      text: action.text
    }, ...state];

  case DELETE_TODO:
    return state.filter(todo =>
      todo.id !== action.id
    );

  default:
    return state;
  }
}

waitFor がない

Reduxには waitFor がないが、Reducer は単純な関数なので、Reducer の処理を合成する Reducer を作り、その中で順番を制御するとよい。

import fooReducer from './fooReducer';
import barReducer from './barReducer';

export default function bazReducer (state, action) {
  const foo = fooReducer(state, action);
  const bar = barReducer(state, action);
  return { foo, bar };
}

ActionCreator もただの関数

facebook/flux だと、↓のようにActionを生成して Dispatcher.dispatch を呼び出す必要があった。 他の Flux 実装でも大体同じ。

var AppDispatcher = require('../dispatcher/AppDispatcher');
var TodoConstants = require('../constants/TodoConstants');

var TodoActions = {
  create: function(text) {
    AppDispatcher.dispatch({
      actionType: TodoConstants.TODO_CREATE,
      text: text
    });
  },

  destroy: function(id) {
    AppDispatcher.dispatch({
      actionType: TodoConstants.TODO_DESTROY,
      id: id
    });
  },
}

Redux では ActionCreator はただの関数として書く。

import * as types from '../constants/ActionTypes';

export function addTodo(text) {
  return {
    type: types.ADD_TODO,
    text
  };
}

export function deleteTodo(id) {
  return {
    type: types.DELETE_TODO,
    id
  };
}

ただ、当然 Dispatcher に登録する必要がある。 登録は Component 側で↓のようにして行う。

// CounterApp.js
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import * as CounterActions from '../actions/CounterActions';

@connect(state => ({
  counter: state.counter
}))
export default class CounterApp {
  render() {
    const { counter, dispatch } = this.props;
    return (
      <Counter counter={counter}
               {...bindActionCreators(CounterActions, dispatch)} />  // dispatcher に bind
    );
  }
}

// Counter.js
import React from 'react';

export default class CounterApp {
  render() {
    return (
      <div onClick={::this.onClick}>
        Count : {counter}
      </div>
    );
  }

  onClick () {
    const { counter, setCounter } = this.props;
    setCounter(counter + 1);
  }
}

所感

state を設計するときの考え方が変わる

これまでの Flux では、アプリケーションの動作に必要な情報をドメイン毎に分け、各ドメインの Store がそれぞれ状態を管理していた。

Redux では、ある時点でのアプリケーションの状態をすべて state に保持する(single source of truth 等と呼ばれる)。
これによって、Reducer, ActionCreator, View を書くときに状態を考える必要がなくなり、かつ hot reload 等もしやすくなる。 さらに、@connect を通じ、どのコンポーネントからでも情報を取り出せる。 (@connect 便利すぎる。なんか Angular の DI 思い出した)

結果、コンポーネントの state を使おうという機運がダダ下がりし、state 地獄を避けられそう。

「View ではないコンポーネント」を多用する

Reducer がただの関数であり、Plain object でないエンティティを必要とするロジックの扱いが難しくなった。 ビジネスロジックを React コンポーネントで処理したくなる事がある。
react-routerみたいな。

個人的にはこういう、「View じゃないのにコンポーネント」っていうの苦手だったんだけど、SPAではこういう書き方が自然なんだな、と感じるようになった。

Modelからイベントを発行しまくれない

Reducer がただの関数になったので、 Store に当たる部分から好き勝手イベントを発行できなくなった。 そのため、定期的にイベントを発行したい場合などは View や AcitionCreator で行う。

僕が今書いてるアプリは

  • View に出てこない Model 側で定期的にイベントを処理する
  • それに追随して View も表示を変える

という要件がある。 当初は他の Flux フレームワークを使い、Store から定期的にイベントを発行するというのを考えていたが、Redux では上手く書けず悩んでいた。

試行錯誤の結果、「View ではないコンポーネント」を使うのが良さそうだった。 イベントを発行する条件を Reducer で処理し、コンポーネント側で定期的に Action を発行する。

// stores/playerReducer.js
export default function playerReducer (state, action) {
  switch (action.type) {
  case START :
    return {
      ...state,
      isPlaying : true
    };
  case STOP :
    return {
      ...state,
      isPlaying : false
    };
  case TICK :
    return {
      ...state,
      tick : state.tick + 1
    };
  default:
    return state;
  }
}

// actions/playerActions.js
export function tick () {
  return {
    type : TICK
  };
}

// components/Player.js
@connect(state => state.player)
class Player extends Component {  

  render () {
    return <App actions={this.props.actions} />;
  }  

  spin () {
    this.props.actions.tick();
    if (this.props.isPlaying) {
      setTimeout(::this.spin, 1000);  // 非同期に Action を発行
    }
  }

}

Player の state で値を管理して App に渡すことでも実現できるが、子コンポーネントにいちいち props を渡す手間を考えると、 Reducer で処理するほうがラクに思える。

ES6, ES7 ガンガン使うことになり楽しい

examples や awesome-redux みると、みんな decorator とか static property とか分割代入とか ::hoge で bindとか、ガンガン使ってる。 楽しい!!!!!!!!!!!!!!!!!!!!!!!!!