前提
http://todomvc.com/examples/react/#/
が作りたい。react.jsのキャッチアップ用エントリー。
実装の流れがメインでreact.jsの解説はほとんどないので、必要に応じて本家のドキュメント参照して下さい。
ソースコード
開始時点のもの
git clone、npm installして開始します
完了(リファクタ後)
リファクタ、データをlocalstrageへ保存、速度改善など(まだあげてない)
準備
準備編。も参照のこと。主にgulp,package.jsのお話。
前回のエントリから続いてやる場合は、以下の対応を行ってください。
ベースとなるhtml,sassを作成
まずはお手本を参考に、静的なhtmlを返す実装と、sassファイルを作成する。これをベースにコンポーネント化をしていく。(お手本とは少しdom構成等異なります)
長いので、以下参照
src/js/app.js
こんな感じになるはず。
実装
stateをつかってレンダリングを行う。
Reactでは、状態(state)の変更を検知して画面の描画を行う。状態(データ)を定義し、そのデータからDOMツリーを作成するように変更する。
まずはリスト部分からスタート。
- getInitialStateの実装
 - renderでstateを利用してliタグの配列を生成して利用
 
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -2,7 +2,33 @@
     var React = require('react/addons');
 
     var TodoApp = React.createClass({
+        getInitialState:function(){
+            return{
+                todos:[
+                    {id:'_1',status:0,label:"call to mom."},
+                    {id:'_2',status:1,label:"walk the dog"},
+                    {id:'_3',status:0,label:"buy groceries for dinner"}
+                ]
+            }
+        },
         render: function(){
+
+            var todoArray = this.state.todos.map(function(todo){
+                var completed = todo.status === 1;
+                return (
+                    <li className={completed ? 'completed todo-list-item' : "todo-list-item" }>
+                        <div className="todo-list-item-view-box">
+                            <input className="todo-list-item-check" type="checkbox" checked={completed}></input>
+                            <span className="todo-list-item-label">{todo.label}</span>
+                            <button className="todo-list-item-remove" type="button"></button>
+                        </div>
+                        <div className="todo-list-item-edit-box">
+                            <input type="text"></input>
+                        </div>
+                    </li>
+                );
+            });
+
             return (
                 <div className="container">
                     <header>
@@ -14,36 +40,7 @@
                             <input className="todo-input" type="text" placeholder="What needs to be done?"></input>
                         </div>
                         <ul className="todo-list">
-                            <li className="todo-list-item">
-                                <div className="todo-list-item-view-box">
-                                    <input className="todo-list-item-check" type="checkbox"></input>
-                                    <span className="todo-list-item-label">aiueo</span>
-                                    <button className="todo-list-item-remove" type="button"></button>
-                                </div>
-                                <div className="todo-list-item-edit-box">
-                                    <input type="text"></input>
-                                </div>
-                            </li>
-                            <li className="todo-list-item completed">
-                                <div className="todo-list-item-view-box">
-                                    <input className="todo-list-item-check" type="checkbox"></input>
-                                    <span className="todo-list-item-label">kakiku</span>
-                                    <button className="todo-list-item-remove" type="button"></button>
-                                </div>
-                                <div className="todo-list-item-edit-box">
-                                    <input type="text"></input>
-                                </div>
-                            </li>
-                            <li className="todo-list-item editing">
-                                <div className="todo-list-item-view-box">
-                                    <input className="todo-list-item-check" type="checkbox"></input>
-                                    <span className="todo-list-item-label">kakiku</span>
-                                    <button className="todo-list-item-remove" type="button"></button>
-                                </div>
-                                <div className="todo-list-item-edit-box">
-                                    <input type="text"></input>
-                                </div>
-                            </li>
+                            {todoArray}
                         </ul>
                         <footer>
                             <span>1 item left</span>
コンポーネントの作成
次に、リストを構成する項目を別コンポーネント(TodoItem)に切り出す。
TodoItemは、現時点ではrenderメソッドのみ。
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -1,32 +1,39 @@
 (function(){
     var React = require('react/addons');
 
+    var TodoItem = React.createClass({
+        render: function(){
+            var todo = this.props.todo;
+            var completed = todo.status === 1;
+            return (
+                <li className={completed ? 'completed todo-list-item' : "todo-list-item" }>
+                    <div className="todo-list-item-view-box">
+                        <input className="todo-list-item-check" type="checkbox" checked={completed}></input>
+                        <span className="todo-list-item-label">{todo.label}</span>
+                        <button className="todo-list-item-remove" type="button"></button>
+                    </div>
+                    <div className="todo-list-item-edit-box">
+                        <input type="text"></input>
+                    </div>
+                </li>
+            );
+        }
+    });
+
     var TodoApp = React.createClass({
         getInitialState:function(){
             return{
                 todos:[
                     {id:'_1',status:0, label:"call to mom."},
                     {id:'_2',status:1, label:"walk the dog"},
                     {id:'_3',status:0, label:"buy groceries for dinner"}
                 ]
             }
         },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
-                var completed = todo.status === 1;
-                return (
-                    <li className={completed ? 'completed todo-list-item' : "todo-list-item" }>
-                        <div className="todo-list-item-view-box">
-                            <input className="todo-list-item-check" type="checkbox" checked={completed}></input>
-                            <span className="todo-list-item-label">{todo.label}</span>
-                            <button className="todo-list-item-remove" type="button"></button>
-                        </div>
-                        <div className="todo-list-item-edit-box">
-                            <input type="text"></input>
-                        </div>
-                    </li>
-                );
+                return <TodoItem key={todo.id} todo={todo}></TodoItem>
             });
 
             return (
イベント処理の実装
次にチェックボックスにチェックを入れた際の挙動を実装する。
チェックボックスにチェックを入れた時の挙動は、
- 対象todoのstatusを変更する。
 - todoの完了がわかるように、打ち消し線を引く
 
ポイントは、状態(state)は親コンポーネントが持つが、イベントは子のコンポーネントから発火するため、発火したイベントを親に伝える必要がある事。
また、通常javascriptで実装する、イベントを取得してビューを書き換えるという考え方ではなく、
状態に即したビューの描画の定義(renderメソッド)を行い、イベント発火時に状態を上書きする。この二つが分離している事に気をつける。
すでにrenderメソッドは状態(state)に応じたビューを描画するようになっているので、stateを上書きするイベントの実装を行う
- 親コンポーネントに状態(state)を変更するメソッドを追加し、子コンポーネントのプロパティとして渡す
 - onChangeイベントで、プロパティとして渡された関数を呼び、状態を変更する
 
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -2,13 +2,16 @@
     var React = require('react/addons');
 
     var TodoItem = React.createClass({
+        handleChange:function(e){
+            this.props.completeItem(this.props.todo.id, e.target.checked);
+        },
         render: function(){
             var todo = this.props.todo;
             var completed = todo.status === 1;
             return (
                 <li className={completed ? 'completed todo-list-item' : "todo-list-item" }>
                     <div className="todo-list-item-view-box">
-                        <input className="todo-list-item-check" type="checkbox" checked={completed}></input>
+                        <input className="todo-list-item-check" type="checkbox" checked={completed} onChange={this.handleChange}></input>
                         <span className="todo-list-item-label">{todo.label}</span>
                         <button className="todo-list-item-remove" type="button"></button>
                     </div>
@@ -30,11 +34,22 @@
                 ]
             }
         },
+        completeItem:function(id,completed){
+            var newTodos = this.state.todos.map(function(todo, index){
+                if(todo.id === id){
+                    return React.addons.update(todo,{status: {$set : (completed ? 1 : 0) }});
+                }else{
+                    return todo;
+                }
+            });
+            this.setState({todos:newTodos});
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
-                return <TodoItem key={todo.id} todo={todo}></TodoItem>
-            });
+                return <TodoItem key={todo.id} todo={todo} completeItem={this.completeItem}></TodoItem>
+            }.bind(this));
+
 
             return (
                 <div className="container">
新たなtodoの追加を行う
次に、新しいtodoが追加された場合の挙動を実装する。
Reactでは、仮想DOMと実際のDOMにずれがないように実装する必要がある。
しかし、新規todoの入力欄に文字を入力した場合、今の実装では仮想DOMと実際のDOMがずれてしまっている(inputのvalueが異なる。)
そのため、ここで行う実装としては、
- 入力を行った際の仮想DOMと実際のDOMがずれないよう、入力値をstateとして持ち、そのstateを利用するようrenderメソッドを修正する
 - inputのonChangeイベントで、入力値のstateを変更する。
 - inputのonKeyPressイベントで、enter押下時に、stateに新しいtodoを追加する。
 
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -31,7 +31,8 @@
                     {id:'_1',status:0, label:"call to mom."},
                     {id:'_2',status:1, label:"walk the dog"},
                     {id:'_3',status:0, label:"buy groceries for dinner"}
-                ]
+                ],
+                newTodoLabel : ''
             }
         },
         completeItem:function(id,completed){
@@ -44,6 +45,29 @@
             });
             this.setState({todos:newTodos});
         },
+        generateId : function(){
+            var num = 4;
+            return function(){
+                return '_' + num++;
+            }
+        }(),
+        handleNewTodoKeyPress: function(e){
+            if(e.charCode == 13){
+                var newTodoLabel = this.state.newTodoLabel.trim();
+                if(newTodoLabel.length > 0){
+                    var newTodo = {id : this.generateId(),status:0,label:newTodoLabel};
+                    this.setState(
+                        {
+                            todos:React.addons.update(this.state.todos,{$push:[newTodo]}),
+                            newTodoLabel: ''
+                        }
+                    );
+                }
+            }
+        },
+        handleNewTodoChange: function(e){
+            this.setState({newTodoLabel:e.target.value});
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
@@ -59,7 +83,7 @@
                     <section className="main-area">
                         <div className="todo-input-area">
                             <input className="check-all-todos" type="checkbox" ></input>
-                            <input className="todo-input" type="text" placeholder="What needs to be done?"></input>
+                            <input className="todo-input" value={this.state.newTodoLabel} type="text" placeholder="What needs to be done?" onChange={this.handleNewTodoChange} onKeyPress={this.handleNewTodoKeyPress}></input>
                         </div>
                         <ul className="todo-list">
                             {todoArray}
全チェックの実装
同様に、このチェックボックスの仮想DOMにも問題があるため、修正する。
- チェックの有無をstateで保持し、それを利用するようrenderを修正
 - チェックボックスのonChangeイベントで、チェックの有無と、各todoのstatusを変更を行う
 
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -32,7 +32,8 @@
                     {id:'_2',status:1, label:"walk the dog"},
                     {id:'_3',status:0, label:"buy groceries for dinner"}
                 ],
-                newTodoLabel : ''
+                newTodoLabel : '',
+                allChecked : false
             }
         },
         completeItem:function(id,completed){
@@ -68,6 +69,18 @@
         handleNewTodoChange: function(e){
             this.setState({newTodoLabel:e.target.value});
         },
+        handleAllCheckChange : function(e){
+            this.setState(
+                {
+                    todos:
+                        this.state.todos.map(function(todo){
+                            return React.addons.update(todo,{status:{$set: (e.target.checked ? 1  : 0)}});
+                        }),
+                    allChecked: e.target.checked
+                }
+
+            );
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
@@ -82,7 +95,7 @@
                     </header>
                     <section className="main-area">
                         <div className="todo-input-area">
-                            <input className="check-all-todos" type="checkbox" ></input>
+                            <input className="check-all-todos" type="checkbox" checked={this.state.allChecked} onChange={this.handleAllCheckChange}></input>
                             <input className="todo-input" value={this.state.newTodoLabel} type="text" placeholder="What needs to be done?" onChange={this.handleNewTodoChange} onKeyPress={this.handleNewTodoKeyPress}></input>
                         </div>
                         <ul className="todo-list">
残todo数表示の実装
stateの状態にあわせて件数が表示されるよう修正。
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -86,7 +86,9 @@
             var todoArray = this.state.todos.map(function(todo){
                 return <TodoItem key={todo.id} todo={todo} completeItem={this.completeItem}></TodoItem>
             }.bind(this));
-
+            var activeTodoCount = this.state.todos.filter(function(todo){
+                return todo.status === 0;
+            }).length;
 
             return (
                 <div className="container">
@@ -102,7 +104,7 @@
                             {todoArray}
                         </ul>
                         <footer>
-                            <span>1 item left</span>
+                            <span>{activeTodoCount} items left</span>
                             <ul className="filter-list">
                                 <li className="filter-list-item">
                                     <a href="#all" className="selected">All</a>
削除ボタンを実装
削除ボタンのonClickイベントから、親コンポーネントに処理を移譲して、該当のtodoを除いた一覧をstateにセットする。
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -5,6 +5,9 @@
         handleChange:function(e){
             this.props.completeItem(this.props.todo.id, e.target.checked);
         },
+        handleRemoveClick:function(e){
+            this.props.removeItem(this.props.todo.id);
+        },
         render: function(){
             var todo = this.props.todo;
             var completed = todo.status === 1;
@@ -13,7 +16,7 @@
                     <div className="todo-list-item-view-box">
                         <input className="todo-list-item-check" type="checkbox" checked={completed} onChange={this.handleChange}></input>
                         <span className="todo-list-item-label">{todo.label}</span>
-                        <button className="todo-list-item-remove" type="button"></button>
+                        <button className="todo-list-item-remove" type="button" onClick={this.handleRemoveClick}></button>
                     </div>
                     <div className="todo-list-item-edit-box">
                         <input type="text"></input>
@@ -81,10 +84,17 @@
 
             );
         },
+        removeItem:function(id){
+            this.setState({
+                todos: this.state.todos.filter(function(todo){
+                    return todo.id !== id;
+                })
+            });
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
-                return <TodoItem key={todo.id} todo={todo} completeItem={this.completeItem}></TodoItem>
+                return <TodoItem key={todo.id} todo={todo} completeItem={this.completeItem} removeItem={this.removeItem}></TodoItem>
             }.bind(this));
             var activeTodoCount = this.state.todos.filter(function(todo){
                 return todo.status === 0;
完了todo の削除を実装
ボタンのonClickイベントから、完了済みを除いたtodoの一覧をセットする
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -91,6 +91,13 @@
                 })
             });
         },
+        handleClearCompletedClick:function(e){
+            this.setState({
+                todos: this.state.todos.filter(function(todo){
+                    return todo.status === 0;
+                })
+            });
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
@@ -126,7 +133,7 @@
                                     <a href="#completed" >Completed</a>
                                 </li>
                             </ul>
-                            <button type="button" className="clear-completed">clear completed</button>
+                            <button type="button" className="clear-completed" onClick={this.handleClearCompletedClick}>clear completed</button>
                         </footer>
                     </section>
                 </div>
編集の実装
- 編集中を表す状態を追加し、その状態に応じて表示が変わるように修正
 - 編集中文字列を状態に追加し、編集用inputタグに表示されるように修正
 - onDoubleClickイベントにて、編集中のstateを変更
 - onChangeイベントにて、編集中文字列のstateを変更
 - onKeyDownイベントにて、enterキー押下で文字列と編集中のstateを変更(文字列が空の場合は削除)
 - onBlurイベントも同様(複数のケースで呼ばれるので注意)
 - onKeyPressイベントにて、escキー押下で編集中のstateを変更
 - ダブルクリックされるとfocusをあてる
 
まずはstateとrenderの変更
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -2,6 +2,11 @@
     var React = require('react/addons');
 
     var TodoItem = React.createClass({
+        getInitialState:function(){
+            return {
+                editingText: ''
+            }
+        },
         handleChange:function(e){
             this.props.completeItem(this.props.todo.id, e.target.checked);
         },
@@ -12,14 +17,17 @@
             var todo = this.props.todo;
             var completed = todo.status === 1;
             return (
-                <li className={completed ? 'completed todo-list-item' : "todo-list-item" }>
+                <li className={todo.editing? "editing todo-list-item": completed ? 'completed todo-list-item' :  "todo-list-item" }>
                     <div className="todo-list-item-view-box">
                         <input className="todo-list-item-check" type="checkbox" checked={completed} onChange={this.handleChange}></input>
                         <span className="todo-list-item-label">{todo.label}</span>
                         <button className="todo-list-item-remove" type="button" onClick={this.handleRemoveClick}></button>
                     </div>
                     <div className="todo-list-item-edit-box">
-                        <input type="text"></input>
+                        <input
+                            type="text"
+                            value={this.state.editingText}
+                            ></input>
                     </div>
                 </li>
             );
@@ -31,9 +39,9 @@
         getInitialState:function(){
             return{
                 todos:[
-                    {id:'_1',status:0, label:"call to mom."},
-                    {id:'_2',status:1, label:"walk the dog"},
-                    {id:'_3',status:0, label:"buy groceries for dinner"}
+                    {id:'_1',status:0, label:"call to mom.", editing:false},
+                    {id:'_2',status:1, label:"walk the dog", editing:false},
+                    {id:'_3',status:0, label:"buy groceries for dinner", editing:false}
                 ],
                 newTodoLabel : '',
                 allChecked : false
状態を変更する各イベントの実装
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -13,12 +13,43 @@
         handleRemoveClick:function(e){
             this.props.removeItem(this.props.todo.id);
         },
+        handleDoubleClick:function(e){
+            this.setState({
+                editingText : this.props.todo.label
+            })
+            this.props.startEditItem(this.props.todo.id);
+        },
+        handleChangeEdit:function(e){
+            this.setState({
+                editingText:e.target.value
+            })
+        },
+        handleKeyDownEdit:function(e){
+            if(e.which === 13){
+                this.completeEditItem();
+            }else if(e.which === 27){
+                this.props.cancelEditItem(this.props.todo.id);
+            }
+        },
+        handleBlurEdit:function(e){
+            if(this.props.todo.editing){
+                this.completeEditItem();
+            }
+        },
+        completeEditItem:function(){
+            var val = this.state.editingText.trim();
+            if(val){
+                this.props.completeEditItem(this.props.todo.id, val);
+            }else{
+                this.props.removeItem(this.props.todo.id);
+            }
+        },
         render: function(){
             var todo = this.props.todo;
             var completed = todo.status === 1;
             return (
                 <li className={todo.editing? "editing todo-list-item": completed ? 'completed todo-list-item' :  "todo-list-item" }>
-                    <div className="todo-list-item-view-box">
+                    <div className="todo-list-item-view-box" onDoubleClick={this.handleDoubleClick}>
                         <input className="todo-list-item-check" type="checkbox" checked={completed} onChange={this.handleChange}></input>
                         <span className="todo-list-item-label">{todo.label}</span>
                         <button className="todo-list-item-remove" type="button" onClick={this.handleRemoveClick}></button>
@@ -27,6 +58,9 @@
                         <input
                             type="text"
                             value={this.state.editingText}
+                            onChange={this.handleChangeEdit}
+                            onKeyDown={this.handleKeyDownEdit}
+                            onBlur={this.handleBlurEdit}
                             ></input>
                     </div>
                 </li>
@@ -106,10 +140,52 @@
                 })
             });
         },
+        startEditItem:function(id){
+            this.setState({
+                todos: this.state.todos.map(function(todo){
+                    return  React.addons.update(todo, {editing:{$set:todo.id === id}});
+                })
+            });
+        },
+        completeEditItem:function(id,newValue){
+            if(id){
+                this.setState({
+                    todos: this.state.todos.map(function(todo){
+                        if(id === todo.id && todo.editing){
+                            return  React.addons.update(todo, {editing:{$set:false},label:{$set:newValue}});
+                        }else{
+                            return todo;
+                        }
+                    })
+                });
+            }
+        },
+        cancelEditItem:function(id){
+            this.setState({
+                todos: this.state.todos.map(function(todo){
+                    if(todo.id === id){
+                        return  React.addons.update(todo, {editing:{$set:false}});
+                    }else{
+                        return todo;
+                    }
+                })
+            });
+        },
         render: function(){
 
             var todoArray = this.state.todos.map(function(todo){
-                return <TodoItem key={todo.id} todo={todo} completeItem={this.completeItem} removeItem={this.removeItem}></TodoItem>
+                return (
+                    <TodoItem
+                        key={todo.id}
+                        todo={todo}
+                        completeItem={this.completeItem}
+                        removeItem={this.removeItem}
+                        startEditItem={this.startEditItem}
+                        completeEditItem={this.completeEditItem}
+                        cancelEditItem={this.cancelEditItem}
+                        >
+                    </TodoItem>
+                );
             }.bind(this));
             var activeTodoCount = this.state.todos.filter(function(todo){
                 return todo.status === 0;
最後に、ダブルクリク時にフォーカスを当てる処理
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -44,6 +44,11 @@
                 this.props.removeItem(this.props.todo.id);
             }
         },
+        componentDidUpdate:function(prevProps){
+            if(!prevProps.todo.editng && this.props.todo.editing){
+                React.findDOMNode(this.refs.editItem).focus();
+            }
+        },
         render: function(){
             var todo = this.props.todo;
             var completed = todo.status === 1;
@@ -56,6 +61,7 @@
                     </div>
                     <div className="todo-list-item-edit-box">
                         <input
+                            ref="editItem"
                             type="text"
                             value={this.state.editingText}
                             onChange={this.handleChangeEdit}
todoのフィルタリング
未完了、完了、全てでフィルタリングする。onClickイベントで実装することもできるが、それぞれ固有のurlを持たせるため、aタグのリンクにする。
directorを利用してルーティングを行う。
npm install director --save
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -1,5 +1,6 @@
 (function(){
     var React = require('react/addons');
+    var Router = require('director').Router;
 
     var TodoItem = React.createClass({
         getInitialState:function(){
@@ -84,9 +85,18 @@
                     {id:'_3',status:0, label:"buy groceries for dinner", editing:false}
                 ],
                 newTodoLabel : '',
-                allChecked : false
+                allChecked : false,
+                filter:'all'
             }
         },
+        componentDidMount:function(){
+            Router({
+                '/':this.setState.bind(this,{filter:'all'}),
+                '/active':this.setState.bind(this,({filter:'active'})),
+                '/completed': this.setState.bind(this,({filter:'completed'}))
+            }).init('/');
+
+        },
         completeItem:function(id,completed){
             var newTodos = this.state.todos.map(function(todo, index){
                 if(todo.id === id){
@@ -179,7 +189,20 @@
         },
         render: function(){
 
-            var todoArray = this.state.todos.map(function(todo){
+            var todoArray = this.state.todos.filter(function(todo){
+
+                switch (this.state.filter){
+                    case 'all':
+                        return true;
+                    case 'active':
+                        return todo.status === 0;
+                    case 'completed':
+                        return todo.status === 1;
+                    default:
+                        return true;
+                }
+            }.bind(this))
+                .map(function(todo){
                 return (
                     <TodoItem
                         key={todo.id}
@@ -214,13 +237,13 @@
                             <span>{activeTodoCount} items left</span>
                             <ul className="filter-list">
                                 <li className="filter-list-item">
-                                    <a href="#all" className="selected">All</a>
+                                    <a href="#/" className={this.state.filter === 'all' && 'selected'}>All</a>
                                 </li>
                                 <li className="filter-list-item">
-                                    <a href="#active" >Active</a>
+                                    <a href="#/active" className={this.state.filter === 'active' && 'selected'}>Active</a>
                                 </li>
                                 <li className="filter-list-item">
-                                    <a href="#completed" >Completed</a>
+                                    <a href="#/completed" className={this.state.filter === 'completed' && 'selected'}>Completed</a>
                                 </li>
                             </ul>
                             <button type="button" className="clear-completed" onClick={this.handleClearCompletedClick}>clear completed</button>
参考
react.addons.update
https://facebook.github.io/react/docs/update.html
