reactjs

ReactでTodoアプリを作る(実装編)

More than 3 years have passed since last update.


前提

http://todomvc.com/examples/react/#/

が作りたい。react.jsのキャッチアップ用エントリー。

実装の流れがメインでreact.jsの解説はほとんどないので、必要に応じて本家のドキュメント参照して下さい。


ソースコード

開始時点のもの

git clonenpm installして開始します

完了(リファクタ前)

完了(リファクタ後)

リファクタ、データをlocalstrageへ保存、速度改善など(まだあげてない)


準備

準備編。も参照のこと。主にgulp,package.jsのお話。

前回のエントリから続いてやる場合は、以下の対応を行ってください。


ベースとなるhtml,sassを作成

まずはお手本を参考に、静的なhtmlを返す実装と、sassファイルを作成する。これをベースにコンポーネント化をしていく。(お手本とは少しdom構成等異なります)

長いので、以下参照

src/js/app.js

src/sass/main.scss

こんな感じになるはず。

todo.png


実装


stateをつかってレンダリングを行う。

Reactでは、状態(state)の変更を検知して画面の描画を行う。状態(データ)を定義し、そのデータからDOMツリーを作成するように変更する。

まずはリスト部分からスタート。


  1. getInitialStateの実装

  2. renderでstateを利用してliタグの配列を生成して利用


app.js

--- 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メソッドのみ。


app.js

--- 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 (



イベント処理の実装

次にチェックボックスにチェックを入れた際の挙動を実装する。

チェックボックスにチェックを入れた時の挙動は、

1. 対象todoのstatusを変更する。

2. todoの完了がわかるように、打ち消し線を引く

ポイントは、状態(state)は親コンポーネントが持つが、イベントは子のコンポーネントから発火するため、発火したイベントを親に伝える必要がある事。

また、通常javascriptで実装する、イベントを取得してビューを書き換えるという考え方ではなく、

状態に即したビューの描画の定義(renderメソッド)を行い、イベント発火時に状態を上書きする。この二つが分離している事に気をつける。

すでにrenderメソッドは状態(state)に応じたビューを描画するようになっているので、stateを上書きするイベントの実装を行う

1. 親コンポーネントに状態(state)を変更するメソッドを追加し、子コンポーネントのプロパティとして渡す

2. onChangeイベントで、プロパティとして渡された関数を呼び、状態を変更する


app.js

--- 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が異なる。)

そのため、ここで行う実装としては、

1. 入力を行った際の仮想DOMと実際のDOMがずれないよう、入力値をstateとして持ち、そのstateを利用するようrenderメソッドを修正する

2. inputのonChangeイベントで、入力値のstateを変更する。

3. inputのonKeyPressイベントで、enter押下時に、stateに新しいtodoを追加する。


app.js

--- 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にも問題があるため、修正する。

1. チェックの有無をstateで保持し、それを利用するようrenderを修正

2. チェックボックスのonChangeイベントで、チェックの有無と、各todoのstatusを変更を行う


app.js

--- 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の状態にあわせて件数が表示されるよう修正。


app.js

--- 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にセットする。


app.js

--- 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の一覧をセットする


app.js

--- 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>



編集の実装


  1. 編集中を表す状態を追加し、その状態に応じて表示が変わるように修正

  2. 編集中文字列を状態に追加し、編集用inputタグに表示されるように修正

  3. onDoubleClickイベントにて、編集中のstateを変更

  4. onChangeイベントにて、編集中文字列のstateを変更

  5. onKeyDownイベントにて、enterキー押下で文字列と編集中のstateを変更(文字列が空の場合は削除)

  6. onBlurイベントも同様(複数のケースで呼ばれるので注意)

  7. onKeyPressイベントにて、escキー押下で編集中のstateを変更

  8. ダブルクリックされるとfocusをあてる

まずはstateとrenderの変更


app.js

--- 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


状態を変更する各イベントの実装


app.js

--- 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;


最後に、ダブルクリク時にフォーカスを当てる処理


app.js

--- 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


app.js

--- 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

director

https://www.npmjs.com/package/director

event

https://facebook.github.io/react/docs/events.html