React useStateで作るシンプルToDoアプリ

React HookのuseStateを使ってできるだけシンプルなReactのTodoアプリケーションの作成を行います。
Reactを使い始めたばかりの人であればuseStateを利用した配列へのオブジェクトの追加、削除、更新方法がわからない人も多いと思います。Todoアプリはそれらの処理をすべて含んでいるのでReactビギナーの人にとってアプリケーション作成の基礎を学ぶのに一番良い教材です。…XXXのspread operatorsが頻繁に出てくるのでこの記事を通して使いこなせるように慣れていきましょう。

目次
環境の構築
任意のディレクトリでnpx crate-react-appを実行してReactのプロジェクトの開発環境を構築します。
$ npx create-react-app my-app
実行が環境したらmy-appディレクトリに移動してnpm startコマンドを実行します。
$ cd my-app
$ npm start
npm startコマンドが完了したら、ブラウザが自動で起動しReactのトップページが表示されれば環境構築は完了です。
ToDoアプリケーションの作成
作成の準備
Reactインストールディレクトリの下にあるsrcディレクトリの下にあるApp.jsファイルを下記のように変更を行います。TodoのアプリケーションのコードはToDoListファイルの中に記述するためToDoListファイルをimportしています。
import TodoList from './components/TodoList';
function App() {
return (
<div style={{ margin: "2em"}}>
<TodoList/>
</div>
);
}
export default App;
TodoList.jsファイルの作成
TodoList.jsファイルはsrcディレクトリの下にcomponentsディレクトリを作成してその下に保存します。
作成開始時のToDoList.jsファイルには下記を記述します。
import { useState } from 'react';
const TodoList = () => {
const initialState = [
{
task: 'Learn vue.js',
isCompleted: false
},
{
task: 'Learn React Hook',
isCompleted: false
},
{
task: 'Learn Gatsby.js',
isCompleted: false
},
]
const [todos, setTodos] = useState(initialState);
return (
<div>
<h1>ToDo List</h1>
</div>
);
}
export default TodoList;
Todoリストの元になる配列initialStateには、3つのタスクがオブジェクトで保存されています。useStateにinitialStateを指定することでtodosにはTodoリストが保存されます。todosの更新にはsetTodosを利用して行います。
ブラウザで確認すると画面にはToDo Listと表示されます。

ToDoリストの表示
ToDoリストを画面上に表示するためにTodoリストが保存されているtodosをmap関数で展開します。
<div>
<h1>ToDo List</h1>
<ul>
{ todos.map((todo, index) => (
<li key={ index }>{ todo.task }</li>
))}
</ul>
</div>
ブラウザで確認を行うとtodosに保存されている3つのタスクがリスト表示されます。

inputタグの設定とuseStateの追加
新しいタスクを追加するためのinputタグをリストの上に追加します。
<h1>ToDo List</h1>
Add Task : <input placeholder="Add New Task" />
<ul>
{ todos.map((todo, index) => (
<li key={ index }>{ todo.task }</li>
))}
</ul>
ブラウザで確認すると入力枠が表示されます。

入力した情報を保持するための新しい変数taskをuseStateで追加します。
const [task, setTask] = useState('')
inputタグに入力した値を取得するためにonChangeイベントを設定し、value属性の値に追加したtaskを指定します。onChangeイベントで実行する関数名は、handleNewTaskとします。
Add Task : <input value={ task } placeholder="Add New Task" onChange={handleNewTask}/>
onChangeイベントを設定すると文字を入力するたびにイベントが発行されます。
イベント時に実行されるhandleNewTask関数を追加します。inputで入力した値はevent.target.valueで取得することができるので、取得できているかconsole.logを利用して確認します。
const handleNewTask = (event) => {
console.log(event.target.value)
}
文字を入力する度にコンソールログに入力した文字が表示されれば正常に動作しているので、console.logからsetTaskに変更を行います。
const handleNewTask = (event) => {
setTask( event.target.value )
}
上記のようにsetTaskに変更すると文字を入力すると入力欄に入力した文字が表示されます。

Formの設定
入力したタスクをtodoリストに保存するためにFormタグを追加する必要があります。FormタグにはonSubmitイベントを追加します。onSubmitイベントで実行する関数名は、handleSubmitとします。ボタンをonSubmitイベントによりEnterを押すとhandelSubmitが実行されます。
<form onSubmit={handleSubmit}>
Add Task :
<input
value={task}
placeholder="Add New Task"
onChange={handleNewTask}
/>
<button type="submit">Add</button>
</form>
タスク追加機能の追加
input要素に入力した文字列をtodosに追加するためにhandleSubmit関数を追加します。
const handleSubmit = (event) => {
event.preventDefault()
if(task === '') return
setTodos(todos => [...todos,{ task, isCompleted: false}])
setTask('')
}
通常のHTMLのフォームではsubmitを実行するとページのリロードが行われます。event.preventDefault()を設定することで通常の動作を停止させています。
handleSubmit関数の中では、useStateで追加した2つの変数のtaskとtodosさらにSetTodosとSetTaskを利用します。
taskには入力欄で入力した文字列が入っているはずなので文字列に何も値がない場合は処理が終わります。文字列が入っている場合は、setTodosによりtodosリストに新しいタスクを追加します。setTaskでは入力欄に入力した文字はTodoリストに追加されるのでその文字を入力欄から削除しています。
下記の部分が既存のTodoリストに入力欄で入力した値を追加している処理です。spread operatorを利用しています。
todos => [...todos,{ task, isCompleted: false}]
ブラウザ上で入力欄に入力した文字がTodoリストに追加されます。追加する際はEnterボタンを押してください。下記では追加により4つのタスクになっています。空白のままEnterを押しても何も起こりません。


setTodosで配列にオブジェクトを追加する前に…todosや{task, isCompleted:false}がどういった形のものなのかコンソールに表示して確認しておきましょう。
if(task === '') return
console.log({task,isCompleted:true});
console.log(...todos);
setTodos(todos => [...todos,{ task, isCompleted: false}])
setTask('')
}
下記のような形であることが確認できます。

タスクの削除機能を追加
タスクの追加が行えたので次はタスクの削除機能を追加します。
liタグにspanタグで囲んでXを追加します。
<li key={ index }>{ todo.task } <span>X</span></li>
タスクの右にXが表示されます。

このXをクリックするとタスクが削除されるように設定を行なっていきます。spanタグにonClickイベントを追加し、関数をhandleRemoveTaskとします。
<li key={ index }>{ todo.task } <span onClick={ () => handleRemoveTask(index) }>X</span></li>
handleRemoveTask関数を追加します。handleRemoveTaskでは削除するタスクを識別するためにindexを渡します。
const handleRemoveTask = index => {
const newTodos = [...todos]
newTodos.splice(index,1)
setTodos(newTodos)
}
handleRemoveTaskの中では現在のTodoリストをspread operatorを利用して新しい配列newTodosに保存します。spliceメソッドを利用して配列のindex番目の要素を1つ削除しています。削除後はsetTodosで新しいTodoリストで既存のTodoリストを書き換えています。
設定後、Xをクリックするとタスクが削除することができます。3つのタスクが削除になり2つになっています。すべてのタスクを削除することも可能です。

タスクの削除ではfilter関数を利用することもできます。filter関数では削除を行うのではなくindexを持ったtodoを取り除いています。
const handleRemoveTask = index => {
const newTodos = [...todos].filter((todo,todoIndex) => todoIndex !== index);
setTodos(newTodos)
}
ここまでの処理でReact HookのuseStateを利用して、タスクを追加、削除するToDoリストが完成しました。完成時の動作は下記の通りです。

タスク更新機能の追加
Xをクリックするとタスク一覧から削除を行っていましたが、削除ではなくタスクの完了か未完了かを表すisCompletedの値を更新します。
handleRemoveTaskをhandleupdateTaskに変更します。
<span onClick={ () => handleUpdateTask(index) }>X</span>
isCompletedがtrueの場合はタスクが完了したのでタスクが完了したことを表すためstyleのtextDecorationLineをline-throughに文字列の上に横棒を設定します。
<ul>
{ todos.map((todo, index) => (
<li
key={ index }
style={ todo.isCompleted === true ? {textDecorationLine: 'line-through'}:{}}
>
{ todo.task }
<span onClick={ () => handleUpdateTask(index) }>X</span></li>
))
}
</ul>
最後にhandleUpdateTaskメソッドを追加します。handleUpdateTaskの引数で設定したindexとtodos配列のindexが一致したtodoのisCompletedの値を現在設定されている値を逆の値に設定しています。trueであればfalseになり、falseであればtrueになります。
const handleUpdateTask = index => {
let newTodos = todos.map((todo,todoIndex) => {
if(todoIndex === index){
todo.isCompleted = !todo.isCompleted
}
return todo;
})
setTodos(newTodos);
}
Xをクリックすると横棒が表示され、横棒が表示されている状態でクリックすると横棒が非表示となります。

削除機能と更新機能を同時追加
削除機能と更新機能を別々に実装しましたが新たにチェックボックスを利用して更新機能を削除機能とは別に追加します。チェックボックはinput要素のtypeをcheckboxとしています。onChageイベントで設定しているhandleUpdateTaskは先ほど更新機能の際に作成したものです。
<ul>
{todos.map((todo, index) => (
<li
key={index}
style={{
textDecoration: todo.isCompleted ? 'line-through' : 'none',
}}
>
<input
type="checkbox"
checked={todo.isCompleted}
onChange={() => handleUpdateTask(index)}
/>
{todo.task}
<span
onClick={() => handleRemoveTask(index)}
style={{ cursor: 'pointer' }}
>
X
</span>
</li>
))}
</ul>

ここまで作成したコード
作成したコード全体は下記となります。
import { useState } from 'react';
const TodoList = () => {
const initialState = [
{
task: 'Learn vue.js',
isCompleted: false,
},
{
task: 'Learn React Hook',
isCompleted: false,
},
{
task: 'Learn Gatsby.js',
isCompleted: false,
},
];
const [todos, setTodos] = useState(initialState);
const [task, setTask] = useState('');
const handleNewTask = (event) => {
setTask(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
if (task === '') return;
setTodos((todos) => [...todos, { task, isCompleted: false }]);
setTask('');
};
const handleRemoveTask = (index) => {
const newTodos = [...todos];
newTodos.splice(index, 1);
setTodos(newTodos);
};
const handleUpdateTask = (index) => {
const newTodos = todos.map((todo, todoIndex) => {
if (todoIndex === index) {
todo.isCompleted = !todo.isCompleted;
}
return todo;
});
setTodos(newTodos);
};
return (
<div>
<h1>ToDo List</h1>
<form onSubmit={handleSubmit}>
Add Task :
<input
value={task}
placeholder="Add New Task"
onChange={handleNewTask}
/>
</form>
<ul>
{todos.map((todo, index) => (
<li
key={index}
style={{
textDecoration: todo.isCompleted ? 'line-through' : 'none',
}}
>
<input
type="checkbox"
checked={todo.isCompleted}
onChange={() => handleUpdateTask(index)}
/>
{todo.task}
<span
onClick={() => handleRemoveTask(index)}
style={{ cursor: 'pointer' }}
>
X
</span>
</li>
))}
</ul>
</div>
);
};
export default TodoList;
コンポーネント化
作成したコードはすべてTodoList.jsファイルに記述されていましたがTodoList.jsファイルからTodo.js, TodList.js, AddTodo.jsファイルの3つのファイルに分割します。
Todo.jsファイルにはtodosの初期値とTodoList.jsとAddTodo.jsファイルをimportしてpropsでtodosとsetTodosを渡します。
import { useState } from 'react';
import AddTodo from './AddTodo';
import TodoList from './TodoList';
const Todo = () => {
const initialState = [
{
task: 'Learn vue.js',
isCompleted: false,
},
{
task: 'Learn React Hook',
isCompleted: false,
},
{
task: 'Learn Gatsby.js',
isCompleted: false,
},
];
const [todos, setTodos] = useState(initialState);
return (
<div>
<h1>ToDo List</h1>
<AddTodo setTodos={setTodos} />
<TodoList todos={todos} setTodos={setTodos} />
</div>
);
};
export default Todo;
AddTodo.jsファイルではタスクの追加の処理のみ記述します。
import { useState } from 'react';
const AddTodo = ({ setTodos }) => {
const [task, setTask] = useState('');
const handleNewTask = (event) => {
setTask(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
if (task === '') return;
setTodos((todos) => [...todos, { task, isCompleted: false }]);
setTask('');
};
return (
<form onSubmit={handleSubmit}>
Add Task :
<input value={task} placeholder="Add New Task" onChange={handleNewTask} />
</form>
);
};
export default AddTodo;
TodoList.jsはpropsのtodosを受け取りtodosを展開して表示し、更新、削除の関数を設定しています。
import React from 'react';
const TodoList = ({ todos, setTodos }) => {
const handleRemoveTask = (index) => {
const newTodos = [...todos];
newTodos.splice(index, 1);
setTodos(newTodos);
};
const handleUpdateTask = (index) => {
const newTodos = todos.map((todo, todoIndex) => {
if (todoIndex === index) {
todo.isCompleted = !todo.isCompleted;
}
return todo;
});
setTodos(newTodos);
};
return (
<ul>
{todos.map((todo, index) => (
<li
key={index}
style={{
textDecoration: todo.isCompleted ? 'line-through' : 'none',
}}
>
<input
type="checkbox"
checked={todo.isCompleted}
onChange={() => handleUpdateTask(index)}
/>
{todo.task}
<span
onClick={() => handleRemoveTask(index)}
style={{ cursor: 'pointer' }}
>
X
</span>
</li>
))}
</ul>
);
};
export default TodoList;
App.jsファイルからはTodoList.jsファイルではTodo.jsファイルをimportします。
import Todo from './componets/Todo';
function App() {
return (
<div style={{ margin: '2em' }}>
<Todo />
</div>
);
}
export default App;
1つのTodoList.jsファイルをコンポーネント化して3つのファイルに分割しましたが処理できる内容は変わりません。