React Meets Redux

先日 React で作ったタスクリストのサンプルを、 React と Redux で書き直してみた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Redux Sample</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.0/react-dom.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.0.4/redux.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.0.0/react-redux.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
  </head>
  <body>
    <div id="content"></div>
    <script type="text/babel">
      // アクションの種類を定義
      const ADD_TASK = "ADD_TASK";
      const SAVE_TASK = "SAVE_TASK";
      const DELETE_TASK = "DELETE_TASK";

      // Action Creator を定義
      function addTask(name) {
        return { type: ADD_TASK, name };
      }
      function saveTask(task, values) {
        return { type: SAVE_TASK, task, values };
      }
      function deleteTask(task) {
        return { type: DELETE_TASK, task };
      }

      // 最初に表示するタスク
      const initialState = [
        { name: "foo", completed: false, editing: false },
        { name: "bar", completed: false, editing: false },
        { name: "hoge", completed: false, editing: false },
        { name: "fuga", completed: false, editing: false }
      ];

      // Reducer を定義
      function taskReducer(state = initialState, action) {
        switch (action.type) {
          case ADD_TASK:
            return state.concat({
              name: action.name,
              completed: false,
              editing: false
            });
          case SAVE_TASK:
            return state.map(t => {
              if (t === action.task) {
                return $.extend(action.task, action.values);
              } else {
                return t;
              }
            });
          case DELETE_TASK:
            return state.filter(t => {
              return t !== action.task;
            });
          default:
            // この Reducer で処理しないアクションのときは
            // state をそのまま返さないといけない
            return state;
        }
      }
      const rootReducer = Redux.combineReducers({
        tasks: taskReducer
      });

      // タスク表示用コンポーネントを定義
      var Task = React.createClass({
        handleEdit(e) {
          this.props.dispatch(saveTask(this.props.task, {
            editing: true
          }));
        },

        handleDelete(e) {
          this.props.dispatch(deleteTask(this.props.task));
        },

        handleChangeCheckbox(e) {
          this.props.dispatch(saveTask(this.props.task, {
            completed: !this.props.task.completed
          }));
        },

        render() {
          return (
            <div>
              <input type="checkbox"
                     checked={this.props.task.completed}
                     onChange={this.handleChangeCheckbox} />
              {(() => {
                if (this.props.task.completed) {
                  return <del>{this.props.task.name}</del>
                } else {
                  return <span>{this.props.task.name}</span>
                }
              }())}
              <a href="#" onClick={this.handleEdit}>[edit]</a>
              <a href="#" onClick={this.handleDelete}>[del]</a>
            </div>
          );
        }
      });

      // タスク編集用コンポーネントを定義
      var EditTaskForm = React.createClass({
        getInitialState() {
          return {
            name: ""
          };
        },

        handleCancel(e) {
          this.props.dispatch(saveTask(this.props.task, {
            editing: false
          }));
        },

        handleSave(e) {
          e.preventDefault();

          if (!this.state.name) {
            return;
          }

          this.props.dispatch(saveTask(this.props.task, {
            name: this.state.name,
            editing: false
          }));

          this.setState({ name: "" });
        },

        handleChangeText(e) {
          this.setState({ name: e.target.value });
        },

        render() {
          return (
            <div>
              <input type="text"
                     value={this.state.name || this.props.task.name}
                     onChange={this.handleChangeText}
                     ref="name"/>
              <button onClick={this.handleSave}>save</button>
              <button onClick={this.handleCancel}>cancel</button>
            </div>
          );
        }
      });

      // タスクリスト表示用コンポーネントを定義
      var TaskList = React.createClass({
        render() {
          var rows = this.props.tasks.map(t => {
            if (t.editing) {
              return (
                <EditTaskForm
                  task={t}
                  dispatch={this.props.dispatch} />
              );
            } else {
              return (
                <Task
                  task={t}
                  dispatch={this.props.dispatch} />
              );
            }
          });

          return (
            <div>
              {rows}
            </div>
          );
        }
      });

      // タスク登録用コンポーネントを定義
      var AddTaskForm = React.createClass({
        handleAdd(e) {
          e.preventDefault();

          var newTask = ReactDOM.findDOMNode(this.refs.name).value.trim();
          if (!newTask) {
            return;
          }

          this.props.dispatch(addTask(newTask));

          ReactDOM.findDOMNode(this.refs.name).value = "";
        },

        render() {
          return (
            <div>
              <input type="text" placeholder="newTask" ref="name"/>
              <button onClick={this.handleAdd}>add</button>
            </div>
          );
        }
      });

      // ルートコンポーネントを定義
      var _TaskApp = React.createClass({
        handleTaskSave(task, values) {
          this.props.dispatch(saveTask(task,values));
        },

        handleTaskDelete(task) {
          this.props.dispatch(deleteTask(task));
        },

        render() {
          return (
            <div>
              <AddTaskForm
                dispatch={this.props.dispatch} />
              <TaskList
                tasks={this.props.tasks}
                dispatch={this.props.dispatch} />
            </div>
          );
        }
      });

      // ルートコンポーネントをデコレート
      var TaskApp = ReactRedux.connect(state => {
        return {
          // state には Reducer を実行した結果が格納されているっぽい
          // combineReducers に Reducer を渡すときのキーと同名みたい
          tasks: state.tasks
        };
      })(_TaskApp);

      // ストアを作成
      var store = Redux.createStore(rootReducer);

      ReactDOM.render(
        <ReactRedux.Provider store={store}>
          <TaskApp />
        </ReactRedux.Provider>,
        document.getElementById("content")
      );
    </script>
  </body>
</html>

React も Redux も Bebel も全部 script タグで CDN から読み込んでいる。 表示に時間はかかるけど、コピペで試せてお手軽。 cdnjs 便利だ。

Redux を使っていて思ったことをざっと書いておこう。

  • state は Redux のストアが一元管理する
  • Redux のストアが state を管理するから React コンポーネントはほとんど state を使わないな
  • アクションは単なるオブジェクト
  • Action Creator はそのアクションを作るだけの関数
  • state を変更するのは Reducer の役割
  • Reducer は state とアクションを受け取って、新しい state を返す関数
  • React コンポーネントの自体のコード量は state を使わないぶん少し減った程度

末端のコンポーネントからコンテナコンポーネントまでイベント(というかコールバック)を伝播して、 コンテナコンポーネントで dispatch を呼び出してみたけど、 これ末端のコンポーネントで dispatch 呼び出したらダメなんだろうか。

Middleware はまだ試していないし、Web API 呼び出しをどこに書けばいいかもまだ掴めていない。 ルーティングは react-router を使えばよさそうかな。

React + Redux なかなか良い感じ。