旧ReactのTodoアプリを関数コンポーネント+Hooksで作り直してみた
目次
- はじめに
- 旧版と新版の開発環境
- コンポーネント構成
- クラスコンポーネントから関数コンポーネントへ
this.stateからuseStateへbind(this)が不要になった理由- Todoの追加処理を比較する
- Todoの完了切り替えを比較する
- Todoの削除処理を比較する
- Todoの検索処理を比較する
- Todoの編集処理を比較する
- propsとコールバック関数
useEffectでlocalStorageへ保存する- JSXの書き方を復習する
- CSSとUIの改善
- 作り直して分かった旧版と新版の違い
- まとめ
- 今後の学習方針
1. はじめに
この記事の目的
以前、React 16系の教材を使い、クラスコンポーネントでTodoアプリを作りました。
当時の実装では、次のような書き方が中心でした。
-
classによるコンポーネント定義 -
constructorとsuper(props) -
this.stateとthis.setState() - イベントハンドラーの
bind(this) - Gulp・Webpack・Babelによる開発環境
- Lodashを使った配列操作
久しぶりにコードを読み返したところ、処理の意味はある程度分かるものの、現在主流のReactとは書き方が大きく異なっていることがわかりました。
そこで今回は、まず旧版のTodoアプリを一度写経して構造を復習し、その後、同じ機能を関数コンポーネントとHooksで作り直しました。
この記事では、単に完成コードを紹介するのではなく、旧版と新版を比較しながら、次の疑問を整理していきます。
-
render()はどこへ行ったのか -
constructorやbind(this)はなぜ不要になったのか -
this.stateはHooksでどう書き換えるのか - Todoの追加・削除・編集は現在どのように実装するのか
-
mapやfilterは何をしているのか -
useEffectはどのような場面で使うのか
Reactを完全に忘れた状態から学び直すのではなく、過去に覚えた知識を現在の書き方へ接続することが目的です。
作り直すことになった背景
復習を始めた当初は、古い教材を最初から最後まで写経することも考えていました。
しかし、教材のコードを確認すると、Reactの基本的な考え方は今でも参考になる一方、実装方法や開発環境には古くなっている部分がありました。
たとえば、旧版ではイベントハンドラーごとに、次のような処理を書いています。
this.callBackRemoveTask =
this.callBackRemoveTask.bind(this);
this.callBackAddTask =
this.callBackAddTask.bind(this);
this.callBackSearch =
this.callBackSearch.bind(this);
当時はクラスコンポーネント内のthisを固定するために必要な処理でした。しかし、関数コンポーネントではthis自体を使用しないため、この処理は登場しません。
また、旧版の開発環境を起動した際には、古いNode.jsを使用する必要があり、BrowserSyncで次のエラーも発生しました。
TypeError: Object.fromEntries is not a function
ビルド自体は成功したものの、自動更新用の開発サーバーが新しい依存パッケージと古いNode.jsの組み合わせで動作しない状態でした。
この経験から、旧版をそのまま覚え直すのではなく、次の流れで復習することにしました。
- 旧版を一度写経して処理の流れを確認する
-
props、state、イベント処理などの基本を復習する - 同じ機能を現在のReactで作り直す
- 旧版と新版の違いを言葉で説明できるようにする
完成したTodoアプリ自体は、追加・編集・削除・検索などを備えた一般的なものです。そのため、アプリそのものを大規模なポートフォリオとして見せるのではなく、Reactの変化を理解するための学習題材として位置付けています。
実装した機能
今回のTodoアプリには、次の機能を実装しました。
- Todoの一覧表示
- Todoの追加
- 完了・未完了の切り替え
- Todoの削除
- Todoの編集
- 先頭一致による検索
- localStorageへの保存
- Todoが0件の場合のメッセージ表示
- スマートフォン向けのレスポンシブ対応
機能を一度に実装するのではなく、常に画面が動く状態を確認しながら、次の順序でコンポーネントを分割しました。
App
├── TodoCreator
├── Search
└── TodoList
└── Task
AppがTodo全体の状態を管理し、各子コンポーネントへデータと操作用の関数を渡す構成です。
対象読者
この記事は、次のような方を想定しています。
- 過去にクラスコンポーネントでReactを学んだ方
- Reactを久しぶりに復習している方
-
useStateやuseEffectを旧式のコードと比較して理解したい方 -
propsやコールバック関数の流れを整理したい方 - Todoアプリを作ったものの、各コードの意味が曖昧な方
Reactの基本文法を網羅する記事ではなく、「以前の書き方を知っている人が、現在の書き方へ移行する過程」を中心に扱います。
2. 旧版と新版の開発環境
旧版:Gulp・Webpack・Babel
旧版のTodoアプリでは、Reactのコードをブラウザで動かすために、主に次の3つを使用していました。
Babelで変換
↓
Webpackでまとめる
↓
Gulpが一連の処理を実行する
それぞれの役割は異なります。
Babel
Babelは、JSXや新しいJavaScript構文を、対象環境で実行可能なJavaScriptへ変換する役割を持ちます。
たとえば、Reactでは次のようなJSXを書きます。
<TodoList data={data} />
JSXは、そのままでは通常のJavaScriptとして解釈できません。そのため、ビルド時にJavaScriptへ変換する必要があります。
旧版ではgulpfile.babel.jsも使用していたため、Gulpの設定ファイルを読み込む際にもBabel関連のモジュールが必要でした。
実際にGulpを実行した際には、次の警告が表示されました。
Failed to load external module: @babel/register
Requiring external module babel-register
この環境では最終的にbabel-registerへフォールバックしてビルドできましたが、Babel 6世代と現在のパッケージ名が混在しており、環境の古さを感じる部分でした。
Webpack
Webpackは、複数のJavaScriptファイルと依存関係をまとめ、ブラウザから読み込めるファイルを生成します。
旧版では、次のように複数のコンポーネントをapp.jsから読み込んでいました。
import TodoList from './components/TodoList';
import TodoCreator from './components/TodoCreator';
import Search from './components/Search';
Webpackはこれらの依存関係をたどり、最終的にbundle.jsへまとめます。
src/js/app.js
├── TodoCreator.js
├── Search.js
├── TodoList.js
└── Task.js
↓
dist/js/bundle.js
実際に次のコマンドを実行すると、bundle.jsが生成されました。
npx gulp build
つまり、bundle.jsは自分で直接編集するファイルではなく、元のソースコードから自動生成される成果物です。
Gulp
Gulpは、ビルドやファイル監視、開発サーバーの起動など、複数の作業をまとめて実行するタスクランナーです。
旧版では、GulpからWebpackのビルドやBrowserSyncの起動を行っていました。
npx gulp
正常に動けば、ファイルの変更を監視し、ブラウザを自動更新できます。
しかし今回の環境では、ビルド後にBrowserSync関連の処理でエラーが発生しました。
TypeError: Object.fromEntries is not a function
使用していたNode.jsが古く、インストールされた依存パッケージ側でObject.fromEntries()が利用されていたことが原因です。
そのため、復習中は次のように作業しました。
npx gulp build
python3 -m http.server 3000
コードを変更するたびにビルドし、ブラウザを手動で再読み込みする必要がありました。
旧環境を理解するという意味では参考になりましたが、新しくReactアプリを作る際に同じ構成を採用する必要性は低いと判断しました。
新版:Vite
新版では、Viteを使ってReactプロジェクトを作成しました。
npm create vite@latest react-todo-modern -- --template react
このコマンドにより、Reactを動かすための基本構成が自動生成されます。
react-todo-modern/
├── src/
│ ├── App.jsx
│ ├── main.jsx
│ └── components/
├── index.html
├── package.json
└── vite.config.js
依存パッケージをインストールし、開発サーバーを起動します。
npm install
npm run dev
起動後は、通常次のようなローカルURLが表示されます。
http://localhost:5173/
旧環境との大きな違いは、ファイルを保存すると変更がすぐブラウザへ反映されることです。
旧版では、
コードを変更
→ Gulpでビルド
→ bundle.jsを生成
→ ブラウザを再読み込み
という流れでした。
Viteでは、
コードを変更
→ 保存
→ ブラウザへ自動反映
となり、復習中の試行錯誤がかなり行いやすくなりました。
Viteによって何が簡単になったのか
Viteを使ったことで、少なくとも今回のような小規模なReactアプリでは、Gulp・Webpack・Babelを個別に設定する必要がなくなりました。
比較すると次のようになります。
| 項目 | 旧版 | 新版 |
|---|---|---|
| Reactの書き方 | クラスコンポーネント | 関数コンポーネント |
| 状態管理 | this.state |
useState |
| ビルド環境 | Gulp・Webpack・Babel | Vite |
| 開発サーバー | BrowserSync | Vite開発サーバー |
| 自動反映 | BrowserSyncで設定 | 標準で利用可能 |
| 設定ファイル | 複数の設定が必要 | 最小限 |
| 起動 | npx gulp |
npm run dev |
| 本番ビルド | Gulp経由 | npm run build |
ここで重要なのは、ViteがReactそのものではないという点です。
ReactはUIを構築するためのライブラリであり、ViteはReactのコードを開発・ビルドするためのツールです。
React:画面と状態を作る
Vite:Reactを開発・ビルドしやすくする
Viteを導入したから関数コンポーネントやHooksが使えるようになったわけではありません。開発環境の変更とReactの書き方の変更は、分けて理解する必要があります。
旧環境を触り直したことで、bundle.jsがどのように生成されていたのか、BabelやWebpackが何を担当していたのかも改めて確認できました。
一方、新しくReactアプリを作る場合は、環境構築そのものへ時間をかけるより、Viteなどで土台を用意し、コンポーネントや状態管理の実装へ集中するほうが効率的だと感じました。
3. コンポーネント構成
Todoアプリの全体構成
旧版と新版では書き方が変わりましたが、画面を役割ごとのコンポーネントに分ける考え方は共通しています。
今回の新版は、次の構成にしました。
src/
├── App.jsx
└── components/
├── TodoCreator.jsx
├── Search.jsx
├── TodoList.jsx
└── Task.jsx
コンポーネントの親子関係は次のとおりです。
App
├── TodoCreator
├── Search
└── TodoList
└── Task
最初からすべてのファイルを作るのではなく、常に画面が動く状態を保ちながら分割しました。
-
App.jsxだけでTodoを一覧表示する -
TodoCreatorを作ってTodoを追加する - 一覧表示を
TodoListへ分離する - Todo1件分を
Taskへ分離する -
Searchを追加して検索できるようにする - 編集・削除・完了切り替えを追加する
機能を一度に実装すると、エラーが発生したときに原因を探しにくくなります。小さく実装し、その都度動作を確認することで、どの変更が問題だったのか判断しやすくなりました。
各コンポーネントの役割
App
AppはTodoアプリ全体の親コンポーネントです。
主に次の役割を持ちます。
- Todo一覧の管理
- 検索文字の管理
- Todoの追加
- Todoの削除
- 完了状態の切り替え
- Todo本文の編集
- 検索結果の作成
- localStorageへの保存
function App() {
const [todos, setTodos] = useState([]);
const [searchText, setSearchText] = useState('');
// 追加・削除・編集などの処理
return (
<main className="todo-app">
<TodoCreator onAdd={addTodo} />
<Search
value={searchText}
onSearch={setSearchText}
/>
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onRemove={removeTodo}
onEdit={editTodo}
/>
</main>
);
}
Todoの元データはAppに集め、子コンポーネントは受け取ったデータの表示や、ユーザー操作の通知を担当します。
TodoCreator
TodoCreatorは、新しいTodoを入力して追加するコンポーネントです。
入力中の文字とエラーメッセージは、このコンポーネント内で管理しています。
function TodoCreator({ onAdd }) {
const [text, setText] = useState('');
const [error, setError] = useState('');
// 入力・追加処理
}
追加が確定すると、親から渡されたonAddを実行します。
onAdd(trimmedText);
TodoCreator自身がTodo一覧を変更するのではなく、入力された文字をAppへ通知する形です。
Search
Searchは検索文字を入力するコンポーネントです。
function Search({ value, onSearch }) {
return (
<input
type="search"
value={value}
onChange={(event) =>
onSearch(event.target.value)
}
/>
);
}
入力値が変わるたびにonSearchを実行し、検索文字をAppへ渡します。
今回、onSearchにはAppのsetSearchTextをそのまま渡しました。
<Search
value={searchText}
onSearch={setSearchText}
/>
そのため、Search側で次を実行すると、
onSearch(event.target.value);
実際にはAppの検索用stateが更新されます。
TodoList
TodoListはTodo配列を受け取り、Todo1件ごとにTaskを生成します。
function TodoList({
todos,
onToggle,
onRemove,
onEdit,
}) {
return (
<ul className="todo-list">
{todos.map((todo) => (
<Task
key={todo.id}
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
onEdit={onEdit}
/>
))}
</ul>
);
}
map()では、todosからTodoを1件ずつ取り出します。
todos.map((todo) => (
<Task key={todo.id} todo={todo} />
))
流れは次のとおりです。
todosから1件取り出す
→ 取り出した1件がtodoへ入る
→ todoをTaskへ渡す
→ Taskの配列が作られる
→ Reactが一覧として表示する
map()は単に要素を取り出すだけではなく、各要素を別の値へ変換し、新しい配列を返すメソッドです。
今回の場合は、Todoオブジェクトの配列をTaskコンポーネントの配列へ変換しています。
Task
TaskはTodo1件分を表示し、次の操作を受け付けます。
- 完了状態の切り替え
- Todo本文の編集
- Todoの削除
function Task({
todo,
onToggle,
onRemove,
onEdit,
}) {
// Todo1件分の表示と操作
}
たとえば削除ボタンを押すと、TodoのIDを引数にしてonRemoveを実行します。
<button
type="button"
onClick={() => onRemove(todo.id)}
>
削除
</button>
ここで実行される処理は、最終的にはAppで定義したremoveTodoです。
親子間のデータの流れ
Reactでは、基本的にデータを親から子へ渡します。
今回のTodo一覧は、次の順番で流れます。
Appのtodos
↓
TodoListのtodos
↓
Taskのtodo
AppからTodoListへは、次のように渡します。
<TodoList todos={filteredTodos} />
TodoListでは、propsとして受け取ります。
function TodoList({ todos }) {
さらに、Todoを1件ずつTaskへ渡します。
<Task todo={todo} />
一方、削除や編集などの操作は子から親へ伝える必要があります。
ただし、データの流れそのものを逆向きにするわけではありません。親が関数を子へ渡し、子がその関数を呼び出します。
Appで関数を定義
↓
TodoListへ渡す
↓
Taskへ渡す
↓
Taskが関数を実行
↓
Appのstateが更新される
削除処理を例にすると、Appで関数を定義します。
function removeTodo(id) {
setTodos((currentTodos) =>
currentTodos.filter(
(todo) => todo.id !== id
)
);
}
その関数をTodoListへ渡します。
<TodoList onRemove={removeTodo} />
TodoListからTaskへ渡します。
<Task onRemove={onRemove} />
最後にTaskが実行します。
onClick={() => onRemove(todo.id)}
この構造を理解すると、「子コンポーネントがなぜ親のTodo一覧を更新できるのか」が分かりやすくなります。
4. クラスコンポーネントから関数コンポーネントへ
旧版のクラスコンポーネント
旧版のAppは、クラスコンポーネントとして定義されていました。
class TodoApp extends React.Component {
constructor(props) {
super(props);
this.state = {
data: [],
searchText: '',
};
}
render() {
return (
<div>
<TodoCreator />
<Search />
<TodoList />
</div>
);
}
}
クラスコンポーネントでは、React.Componentを継承し、render()メソッドからJSXを返します。
class TodoApp extends React.Component
このextendsは、TodoAppにReactコンポーネントとして必要な機能を引き継がせるためのものです。
新版の関数コンポーネント
新版では、通常のJavaScript関数としてコンポーネントを定義しました。
function App() {
return (
<main>
<TodoCreator />
<Search />
<TodoList />
</main>
);
}
クラスコンポーネントにあった次の要素がなくなっています。
classextends React.Componentconstructorsuper(props)render()this
関数コンポーネントでは、関数自体がReactから呼び出され、その戻り値として表示内容のJSXを返します。
render()はどこへ行ったのか
旧版では、表示内容をrender()の中から返していました。
class Task extends React.Component {
render() {
return (
<li>{this.props.text}</li>
);
}
}
新版では、コンポーネント関数から直接返します。
function Task({ todo }) {
return (
<li>{todo.text}</li>
);
}
そのため、感覚的には次の対応になります。
旧版のrender()
↓
関数コンポーネント本体
ただし、厳密にはrenderとreturnは同じものではありません。
-
render()はクラスに定義するメソッド -
returnはJavaScript関数から値を返す構文
関数コンポーネントでは、Reactがコンポーネント関数を実行し、そのreturnから受け取ったJSXを基に画面を描画します。
stateが更新された場合も、Reactがコンポーネント関数を再実行し、新しいJSXを取得します。
stateが更新される
↓
コンポーネント関数が再実行される
↓
新しいJSXが返される
↓
必要な部分だけ画面へ反映される
JSXとは何か
JSXは、JavaScriptの中でHTMLに似た記法を使い、UIを表現するための構文です。
<h1>TODOS</h1>
見た目はHTMLですが、実際にはJavaScriptの一部として処理されます。
JSX内でJavaScriptの値を使用する場合は、波括弧を使います。
<span>{todo.text}</span>
ここでの{}は、「この中をJavaScriptの式として評価する」という意味です。
this専用の構文ではありません。関数コンポーネントでも同じように使います。
checked={todo.isDone}
onClick={() => onRemove(todo.id)}
JSXが使える理由は、単にReactをimportしたからではありません。Viteなどの開発環境がJSXを処理できる形へ変換しているためです。
constructorが不要になった理由
旧版では、初期stateを定義するためにconstructorを使用していました。
constructor(props) {
super(props);
this.state = {
data: [],
searchText: '',
};
}
constructorは、クラスからインスタンスが作られるときに実行される初期化処理です。
その中でthisを使用する前に、親クラスのコンストラクタを呼び出す必要があります。
super(props);
そのため、クラスコンポーネントではconstructorとsuper(props)がセットで登場していました。
関数コンポーネントはクラスではなく、インスタンスも作りません。そのため、constructorもsuper(props)も不要です。
stateの初期値はuseStateへ直接渡します。
const [todos, setTodos] = useState([]);
propsの受け取り方
旧版では、propsをthis.propsから取得していました。
this.props.id
this.props.text
this.props.isDone
新版では、関数の引数としてpropsを受け取ります。
function Task(props) {
return <li>{props.todo.text}</li>;
}
さらに、分割代入を使うと必要なpropsを直接取り出せます。
function Task({
todo,
onToggle,
onRemove,
onEdit,
}) {
これにより、コンポーネント内ではthis.propsを付けずに使用できます。
todo.text
onToggle(todo.id)
onRemove(todo.id)
propsをstateへコピーするときの注意
旧版のTaskでは、受け取ったpropsをstateへコピーしていました。
this.state = {
id: this.props.id,
text: this.props.text,
isDone: this.props.isDone,
};
この方法では、親が持つisDoneと、Task自身が持つisDoneの2か所に同じ状態が存在します。
AppのisDone
TaskのisDone
両者の更新タイミングがずれると、表示と実データが一致しなくなる可能性があります。
新版では、完了状態の正しい値をAppだけで管理し、Taskは受け取った値をそのまま表示します。
<input
type="checkbox"
checked={todo.isDone}
onChange={() => onToggle(todo.id)}
/>
編集欄へ入力中の文字など、コンポーネント内だけで一時的に必要な値は、Task自身のstateとして持たせます。
const [editMode, setEditMode] = useState(false);
const [text, setText] = useState(todo.text);
つまり、すべてのpropsをstateへコピーするのではなく、「どのコンポーネントがその状態を管理すべきか」を考える必要があります。
5. this.stateからuseStateへ
旧版のstate定義
旧版では、コンポーネントの状態をthis.stateへまとめて定義していました。
constructor(props) {
super(props);
this.state = {
data: [
{
id: this.createHashId(),
text: 'sample todo1',
isDone: false,
},
{
id: this.createHashId(),
text: 'sample todo2',
isDone: false,
},
],
searchText: '',
};
}
ここでのstateは、単なる変数ではありません。
stateが変更されると、Reactがコンポーネントを再描画し、変更内容を画面へ反映します。
通常の変数を書き換えただけでは、Reactは画面を更新すべきタイミングを認識できません。
新版のuseState
新版では、useStateをimportして状態を定義します。
import { useState } from 'react';
Todo一覧は次のように定義しました。
const [todos, setTodos] = useState([
{
id: crypto.randomUUID(),
text: 'sample todo1',
isDone: false,
},
{
id: crypto.randomUUID(),
text: 'sample todo2',
isDone: false,
},
]);
基本形は次のとおりです。
const [現在の値, 更新関数] =
useState(初期値);
今回の場合は、
todos :現在のTodo一覧
setTodos :Todo一覧を更新する関数
となります。
検索文字も別のstateとして定義します。
const [searchText, setSearchText] =
useState('');
クラスコンポーネントでは、複数の値を1つのthis.stateオブジェクトへまとめていました。
this.state = {
data: [],
searchText: '',
};
関数コンポーネントでは、状態の役割ごとにuseStateを分けられます。
const [todos, setTodos] = useState([]);
const [searchText, setSearchText] =
useState('');
更新関数の実体
setTodosやsetSearchTextは、自分で定義した関数ではありません。
useStateを呼び出したときに、Reactが現在値と一緒に返してくれる更新関数です。
const [text, setText] = useState('');
この場合は、
-
textが現在の値 -
setTextが値を更新する関数
です。
setText('新しい文字');
を実行すると、Reactがstateを更新し、コンポーネントを再レンダリングします。
名前は自由に決められます。
const [text, changeText] = useState('');
これでも動作します。ただし、一般的には状態名の前にsetを付けます。
todos → setTodos
searchText → setSearchText
editMode → setEditMode
text → setText
stateを直接変更しない
旧版のコードでは、配列やオブジェクトを直接変更している部分がありました。
let nextData = this.state.data;
nextData.push({
id: this.createHashId(),
text: val,
isDone: false,
});
this.setState({
data: nextData,
});
このコードでは、nextDataとthis.state.dataが同じ配列を参照しています。
nextData ───────┐
├── 同じ配列
this.state.data ┘
そのため、push()すると元のstateも直接変更されます。
新版では、スプレッド構文を使って新しい配列を作りました。
setTodos((currentTodos) => [
...currentTodos,
newTodo,
]);
...currentTodosで既存のTodoを新しい配列へ展開し、その末尾へnewTodoを追加しています。
元の配列
[todo1, todo2]
新しい配列
[todo1, todo2, newTodo]
元の配列を直接変更しないため、Reactが変更前と変更後を判別しやすくなります。
更新前の値を受け取る関数形式
stateの更新関数には、新しい値を直接渡せます。
setSearchText('sample');
一方、更新前の値を使って次の値を作る場合は、関数を渡します。
setTodos((currentTodos) => [
...currentTodos,
newTodo,
]);
このcurrentTodosは、自分で事前に定義した変数ではありません。
setTodosへ関数を渡すと、Reactが更新直前の最新のstateを第1引数として渡します。
React
↓ 最新のtodosを渡す
currentTodos
↓ 新しい配列を作る
更新後のtodos
名前は自由なので、次のように書いても同じです。
setTodos((prevTodos) => [
...prevTodos,
newTodo,
]);
よく使われる名前はprevTodosやcurrentTodosです。
基本形は次のとおりです。
setState(新しい値);
または、
setState((更新前の値) => {
return 新しい値;
});
現在のstateを基に追加・削除・切り替えを行う場合は、関数形式を使うと安全です。
アロー関数のreturn省略
今回のstate更新では、次のようなアロー関数を使用しました。
setTodos((currentTodos) =>
currentTodos.filter(
(todo) => todo.id !== id
)
);
=>の直後に式を1つだけ書く場合は、returnを省略できます。
const double = (number) => number * 2;
省略せずに書くと、次と同じです。
const double = (number) => {
return number * 2;
};
Todoの削除処理を省略せずに書くと、次のようになります。
setTodos((currentTodos) => {
return currentTodos.filter((todo) => {
return todo.id !== id;
});
});
短い書き方と長い書き方のどちらを使っても、処理内容は同じです。
学習中に処理を追いにくい場合は、一度{}とreturnを明示して書くと理解しやすくなります。
Todoの完了状態を更新する
完了状態の切り替えにはmap()を使いました。
function toggleTodo(id) {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id
? {
...todo,
isDone: !todo.isDone,
}
: todo
)
);
}
処理の流れは次のとおりです。
- Reactから更新前のTodo一覧を受け取る
-
map()でTodoを1件ずつ確認する - IDが一致したTodoだけ更新する
- 一致しないTodoはそのまま返す
- 新しいTodo配列を
setTodosへ返す
三項演算子を使わずに書くと、次のようになります。
function toggleTodo(id) {
setTodos((currentTodos) => {
return currentTodos.map((todo) => {
if (todo.id === id) {
return {
...todo,
isDone: !todo.isDone,
};
}
return todo;
});
});
}
次の部分では、元のTodoオブジェクトを展開し、isDoneだけを反転しています。
{
...todo,
isDone: !todo.isDone,
}
たとえば元のTodoが次の場合、
{
id: 'abc',
text: 'Reactを復習する',
isDone: false,
}
更新後は次になります。
{
id: 'abc',
text: 'Reactを復習する',
isDone: true,
}
元のオブジェクトを直接変更せず、新しいオブジェクトを返している点が重要です。
Todoを削除する
削除処理ではfilter()を使用しました。
function removeTodo(id) {
setTodos((currentTodos) =>
currentTodos.filter(
(todo) => todo.id !== id
)
);
}
filter()は、条件がtrueになった要素だけを残した新しい配列を返します。
todo.id !== id
削除対象とIDが異なるTodoはtrueになるため残ります。削除対象だけがfalseになり、新しい配列から除外されます。
todo1:IDが違う → true → 残る
todo2:IDが一致 → false → 除外
todo3:IDが違う → true → 残る
旧版ではLodashのreject()を使っていました。
_.reject(this.state.data, { id });
reject()は条件に一致した要素を除外します。一方、filter()は条件を満たした要素を残します。
今回は標準のJavaScriptだけで十分に表現できるため、Lodashを使わずに実装しました。
stateは「Reactが管理する画面の状態」
復習前は、stateを「Reactにおける変数宣言」のように捉えていました。
しかし、通常の変数とstateには明確な違いがあります。
let count = 0;
通常の変数を変更しても、それだけではReactの画面更新につながりません。
const [count, setCount] = useState(0);
stateを更新関数で変更すると、Reactが再レンダリングを行います。
したがって、stateは単なる変数ではなく、次のように考えるほうが正確です。
stateとは、Reactが管理し、変更時に画面の再描画につながる状態データである。
Todo一覧、検索文字、編集中の文字、編集モードなど、画面表示に影響する値をstateとして管理することで、データの状態と画面表示を同期できます。
6. bind(this)が不要になった理由
旧版で行っていたbind
旧版のクラスコンポーネントでは、constructor内に次のコードを書いていました。
this.callBackRemoveTask =
this.callBackRemoveTask.bind(this);
this.callBackAddTask =
this.callBackAddTask.bind(this);
this.callBackSearch =
this.callBackSearch.bind(this);
this.callBackToggleDone =
this.callBackToggleDone.bind(this);
this.filterCollection =
this.filterCollection.bind(this);
最初に見たときは、「ファイル内で使用するメソッドは、すべてbindする決まりなのか」と疑問に感じました。
しかし、すべてのメソッドを必ずbindするわけではありません。
bindが必要になるのは、クラスのメソッドをコールバック関数として渡し、そのメソッド内でthisを使用する場合です。
クラスコンポーネントにおけるthis
JavaScriptのthisは、関数がどのように呼び出されたかによって参照先が変わります。
クラスのメソッドを通常どおり呼び出す場合は、thisからコンポーネントへアクセスできます。
this.callBackAddTask('新しいTodo');
しかし、メソッドそのものをイベントや子コンポーネントへ渡すと、呼び出されたときにthisとの結び付きが失われる場合があります。
<TodoCreator
callBackAddTask={this.callBackAddTask}
/>
その状態でメソッド内から次を実行しようとすると、thisが期待したコンポーネントを指さず、エラーになる可能性があります。
this.setState({
data: nextData,
});
そこで、次のようにbindしていました。
this.callBackAddTask =
this.callBackAddTask.bind(this);
右側のthisは、現在のコンポーネントインスタンスです。
つまり、このコードは次の意味になります。
callBackAddTaskがどこから呼び出されても、メソッド内のthisが現在のコンポーネントを指すように固定する。
bindは、stateや引数をメソッドへ渡しているわけではありません。固定しているのは、メソッド内で使用されるthisの参照先です。
bindが必要なメソッドと不要なメソッド
クラス内で定義したメソッドでも、必ずbindが必要になるわけではありません。
たとえば、次のメソッドは内部でthisを使っていません。
createHashId() {
return Math.random()
.toString(36)
.slice(-16);
}
また、このメソッドはコールバックとして別の場所へ渡さず、クラス内から直接呼び出していました。
id: this.createHashId()
そのため、createHashIdはbindしていません。
一方、次のメソッドは子コンポーネントへ渡され、内部でthis.setStateを使用します。
callBackSearch(val) {
this.setState({
searchText: val,
});
}
このようなメソッドはbindの対象になります。
関数コンポーネントではthisを使わない
新版では、コンポーネントを関数として定義します。
function App() {
const [todos, setTodos] = useState([]);
function addTodo(text) {
setTodos((currentTodos) => [
...currentTodos,
{
id: crypto.randomUUID(),
text,
isDone: false,
},
]);
}
return (
<TodoCreator onAdd={addTodo} />
);
}
関数コンポーネントでは、stateも更新関数も通常の変数として参照できます。
todos
setTodos
this.stateやthis.setStateを使用しないため、thisの参照先を固定する必要もありません。
したがって、次のような処理は不要になります。
addTodo = addTodo.bind(this);
これはReactがbindを自動実行しているわけではありません。
関数コンポーネントでは、そもそも
thisを使わないためbindが不要になる。
というのが正確な理解です。
7. Todoの追加処理を比較する
旧版の追加処理
旧版では、Appに次のメソッドを定義していました。
callBackAddTask(val) {
let nextData = this.state.data;
nextData.push({
id: this.createHashId(),
text: val,
isDone: false,
});
this.setState({
data: nextData,
});
}
TodoCreatorから入力文字を受け取り、Todoオブジェクトを作成して配列へ追加しています。
TodoのIDには、次のメソッドで生成した文字列を使用していました。
createHashId() {
return Math.random()
.toString(36)
.slice(-16);
}
処理内容は次のとおりです。
-
Math.random()で乱数を作る -
toString(36)で数字とアルファベットを使った文字列へ変換する -
slice(-16)で末尾から最大16文字を取り出す
手軽なID生成方法ですが、厳密に重複しないことが保証されるわけではありません。
旧版の問題点
次の代入では、配列を複製していません。
let nextData = this.state.data;
nextDataとthis.state.dataは、同じ配列を参照しています。
その状態でpush()すると、元のstateを直接変更することになります。
nextData.push(newTodo);
Reactでは、stateを直接変更せず、新しい配列やオブジェクトを作って更新することが推奨されます。
新版の追加処理
新版では、次のように実装しました。
function addTodo(text) {
const newTodo = {
id: crypto.randomUUID(),
text,
isDone: false,
};
setTodos((currentTodos) => [
...currentTodos,
newTodo,
]);
}
まず、新しく追加するTodoを作成します。
const newTodo = {
id: crypto.randomUUID(),
text,
isDone: false,
};
次に、既存のTodoを新しい配列へ展開し、末尾へnewTodoを追加します。
[
...currentTodos,
newTodo,
]
元の配列が次の場合、
[todo1, todo2]
更新後は次の新しい配列になります。
[todo1, todo2, newTodo]
push()とは異なり、元の配列を直接変更していません。
text: textを省略できる理由
オブジェクトのプロパティ名と変数名が同じ場合は、省略記法を使えます。
const newTodo = {
text: text,
};
これは次のように書けます。
const newTodo = {
text,
};
今回の記事では処理を明確にするため、最初はtext: textと書いても問題ありません。慣れてきたら省略記法を使うと簡潔になります。
crypto.randomUUID()によるID生成
新版では、次を使用しました。
crypto.randomUUID()
これにより、TodoごとにUUID形式の識別子を生成できます。
2dcf86b5-3dd5-4d51-b19b-...
TodoのIDは画面へ表示するためではなく、主に次の用途で使います。
- Reactの
key - 削除するTodoの特定
- 編集するTodoの特定
- 完了状態を切り替えるTodoの特定
<Task
key={todo.id}
todo={todo}
/>
TodoCreatorからAppへ入力値を渡す
新版のTodoCreatorでは、入力内容を自身のstateで管理します。
const [text, setText] = useState('');
フォームが送信されると、最初にページの再読み込みを防ぎます。
function handleSubmit(event) {
event.preventDefault();
}
前後の空白を取り除きます。
const trimmedText = text.trim();
空文字の場合はエラーを表示します。
if (!trimmedText) {
setError('入力が空です');
return;
}
問題がなければ、親から渡されたonAddを実行します。
onAdd(trimmedText);
Appでは、onAddとしてaddTodoを渡しています。
<TodoCreator onAdd={addTodo} />
したがって、実際の流れは次のようになります。
フォームへ入力
↓
TodoCreatorが文字を取得
↓
onAdd(text)を実行
↓
AppのaddTodo(text)が実行される
↓
todosが更新される
↓
画面が再レンダリングされる
8. Todoの完了切り替えを比較する
旧版の完了切り替え
旧版では、Todo一覧をループし、IDが一致するTodoを探していました。
callBackToggleDone(id) {
let data = [];
for (let i in this.state.data) {
if (this.state.data[i].id === id) {
data = this.state.data[i];
data.isDone = !data.isDone;
this.state.data.splice(
i,
1,
data
);
}
}
}
splice()は「スプライス」と読みます。
今回の次のコードは、配列のi番目を1件削除し、その位置へdataを入れています。
this.state.data.splice(i, 1, data);
引数の意味は次のとおりです。
splice(
開始位置,
削除する件数,
追加する要素
)
したがって、
splice(i, 1, data)
は「i番目を1件、dataで置き換える」という処理です。
旧版の問題点
旧版では、Todoオブジェクトを直接変更しています。
data.isDone = !data.isDone;
さらに、state内の配列に対して直接splice()を実行しています。
this.state.data.splice(i, 1, data);
this.setState()も呼び出していません。
子のTaskでもisDoneをstateとして管理していたため、クリック直後の見た目は切り替わる可能性があります。
this.setState({
isDone: !this.state.isDone,
});
しかし、親と子の両方がisDoneを持つため、検索や再描画のタイミングによって状態がずれる原因になります。
Appが持つisDone
Taskが持つisDone
同じ意味の状態を複数箇所で管理すると、どちらが正しい値なのか分かりにくくなります。
新版ではAppだけが完了状態を管理する
新版では、Todo一覧を持つAppだけがisDoneを管理します。
function toggleTodo(id) {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id
? {
...todo,
isDone: !todo.isDone,
}
: todo
)
);
}
map()はTodoを1件ずつ確認し、処理後の要素から新しい配列を作ります。
IDが一致した場合は、新しいTodoオブジェクトを返します。
{
...todo,
isDone: !todo.isDone,
}
IDが一致しない場合は、元のTodoをそのまま返します。
todo
三項演算子を使わずに書くと、次のようになります。
function toggleTodo(id) {
setTodos((currentTodos) => {
return currentTodos.map((todo) => {
if (todo.id === id) {
return {
...todo,
isDone: !todo.isDone,
};
}
return todo;
});
});
}
{ ...todo }が行っていること
次の...todoは、Todoオブジェクトのプロパティを新しいオブジェクトへ展開しています。
{
...todo,
isDone: !todo.isDone,
}
元のTodoが次の場合、
{
id: 'abc',
text: 'Reactを復習する',
isDone: false,
}
まず...todoによって、すべてのプロパティがコピーされます。
{
id: 'abc',
text: 'Reactを復習する',
isDone: false,
}
その後ろに書いたisDoneで値を上書きします。
isDone: !todo.isDone
結果は次のようになります。
{
id: 'abc',
text: 'Reactを復習する',
isDone: true,
}
元のTodoオブジェクトは変更されません。
Taskから完了操作を通知する
Taskではチェックボックスを表示しています。
<input
type="checkbox"
checked={todo.isDone}
onChange={() => onToggle(todo.id)}
/>
checkedには、現在の完了状態を渡します。
checked={todo.isDone}
チェック状態が変更されると、TodoのIDを付けてonToggleを実行します。
onChange={() => onToggle(todo.id)}
データの流れは次のとおりです。
Taskでチェック
↓
onToggle(todo.id)
↓
AppのtoggleTodo(id)
↓
todos内のisDoneを更新
↓
更新後のtodoがTaskへ渡される
↓
チェック状態と表示が変わる
状態の正しい値をAppだけに置くことで、親子間のずれを防いでいます。
9. Todoの削除処理を比較する
旧版のLodash reject
旧版では、Lodashをimportしていました。
import _ from 'lodash';
削除処理ではreject()を使用しています。
callBackRemoveTask(id) {
const data = _.reject(
this.state.data,
{ id: id }
);
this.setState({
data: data,
});
}
reject()は、条件に一致した要素を除外した新しい配列を返します。
_.reject(this.state.data, { id: id })
これは、指定されたIDを持つTodoを取り除く処理です。
当初は「rejectが配列から直接削除しているのか」と疑問に感じました。
正確には、元の配列から要素を直接削除するのではなく、条件に一致した要素を含まない新しい配列を返しています。
新版では標準のfilter()を使用する
新版では、JavaScript標準のfilter()を使用しました。
function removeTodo(id) {
setTodos((currentTodos) =>
currentTodos.filter(
(todo) => todo.id !== id
)
);
}
filter()は、コールバック関数がtrueを返した要素だけを残します。
todo.id !== id
この条件は、次の意味です。
削除対象のIDと異なるTodoだけを残す。
たとえば、削除対象がid: 2の場合は次のようになります。
id: 1 → 2と異なる → true → 残す
id: 2 → 2と同じ → false → 除外
id: 3 → 2と異なる → true → 残す
結果として、id: 2だけがない新しい配列が作られます。
reject()とfilter()の違い
両者の考え方は逆です。
reject:条件に一致した要素を除外する
filter:条件を満たした要素を残す
旧版:
_.reject(todos, { id });
新版:
todos.filter((todo) => todo.id !== id);
標準のJavaScriptだけで簡潔に書けるため、今回の削除処理ではLodashを導入する必要がありませんでした。
外部ライブラリが不要という意味ではなく、標準機能で十分に表現できる処理へ、必ずライブラリを使う必要はないということです。
Taskから削除対象のIDを渡す
Taskの削除ボタンでは、現在のTodoのIDを渡します。
<button
type="button"
onClick={() => onRemove(todo.id)}
aria-label={`${todo.text}を削除`}
>
<Trash2
size={18}
aria-hidden="true"
/>
</button>
onClickへ次のように書くと、描画時に関数が即座に実行されてしまいます。
onClick={onRemove(todo.id)}
クリック時に実行するには、関数として渡す必要があります。
onClick={() => onRemove(todo.id)}
このアロー関数は、クリックされたときに呼び出され、その中でonRemove(todo.id)を実行します。
また、削除操作は見た目上アイコンだけですが、iタグではなくbutton要素を使いました。
<button type="button">
<Trash2 />
</button>
これにより、マウスだけでなくキーボードでも操作できます。
アイコン自体は装飾として扱います。
aria-hidden="true"
ボタンの目的はaria-labelで伝えます。
aria-label={`${todo.text}を削除`}
見た目だけでなく、操作性とアクセシビリティも考慮した実装にしました。
10. Todoの検索処理を比較する
旧版の検索処理
旧版では、検索文字をstateへ保存していました。
callBackSearch(val) {
this.setState({
searchText: val,
});
}
検索条件には正規表現を使用しています。
filterCollection(elm) {
const regexp = new RegExp(
'^' + this.state.searchText,
'i'
);
return elm.text.match(regexp);
}
表示時に、検索文字がある場合だけfilter()を実行します。
const data = this.state.searchText
? this.state.data.filter(
this.filterCollection
)
: this.state.data;
RegExpとは何か
RegExpは、文字列のパターンを表す正規表現オブジェクトです。
new RegExp('^sample', 'i')
第1引数が検索パターン、第2引数がフラグです。
'^' + this.state.searchText
^は「文字列の先頭」を表します。
たとえば、検索文字がsampleの場合は次のパターンになります。
^sample
これは、「sampleから始まる文字列」を意味します。
第2引数のiは、大文字と小文字を区別しない指定です。
new RegExp('^sample', 'i')
そのため、次の文字列はいずれも一致します。
sample todo
Sample todo
SAMPLE TODO
filter()の引数はどこから来るのか
旧版では次のように、関数そのものをfilter()へ渡していました。
this.state.data.filter(
this.filterCollection
)
このとき、filterCollectionの引数elmには、Reactやthis.stateが自動で渡されるわけではありません。
filterCollection(elm) {
配列のfilter()が、対象配列の要素を1件ずつ渡しています。
this.state.dataの1件目
↓
filterCollection(elm)
this.state.dataの2件目
↓
filterCollection(elm)
どの配列の要素が渡されるかは、filter()の左側で決まります。
this.state.data.filter(callback);
この場合は、this.state.dataの各要素が渡されます。
data2.filter(callback);
この場合は、data2の各要素が渡されます。
this.state全体が自動で渡されているわけではありません。
新版の検索文字管理
新版では、検索文字をuseStateで管理します。
const [searchText, setSearchText] =
useState('');
Searchへ現在値と更新関数を渡します。
<Search
value={searchText}
onSearch={setSearchText}
/>
Searchでは、入力が変更されるたびにonSearchを実行します。
function Search({ value, onSearch }) {
return (
<input
type="search"
value={value}
onChange={(event) =>
onSearch(event.target.value)
}
placeholder="Todoを検索"
/>
);
}
ここでonSearchとして渡されているのは、AppのsetSearchTextです。
そのため、入力欄へ文字を入力すると、AppのsearchTextが更新されます。
新版の検索処理
新版では、正規表現を使わずにstartsWith()で先頭一致を判定しました。
const filteredTodos = todos.filter((todo) =>
todo.text
.toLowerCase()
.startsWith(
searchText.toLowerCase()
)
);
長く見えますが、処理を分解すると単純です。
const filteredTodos = todos.filter(
(todo) => {
const todoText =
todo.text.toLowerCase();
const keyword =
searchText.toLowerCase();
return todoText.startsWith(keyword);
}
);
処理の流れは次のとおりです。
-
todosからTodoを1件取り出す - Todo本文を小文字へ変換する
- 検索文字も小文字へ変換する
- Todo本文が検索文字から始まるか判定する
-
trueになったTodoだけ残す
toLowerCase()の役割
toLowerCase()は、文字列内の英字を小文字へ変換します。
'React'.toLowerCase();
// 'react'
Todo本文と検索文字の両方を小文字にすることで、大文字と小文字を区別せず検索できます。
'React'.toLowerCase();
// 'react'
'RE'.toLowerCase();
// 're'
比較すると、次はtrueになります。
'react'.startsWith('re');
// true
startsWith()の役割
startsWith()は、文字列が指定された文字から始まるかを判定し、trueまたはfalseを返します。
'sample todo'.startsWith('sample');
// true
'sample todo'.startsWith('todo');
// false
今回は旧版と同じく、先頭一致検索にしました。
途中の文字も検索対象にしたい場合は、includes()へ変更できます。
todo.text
.toLowerCase()
.includes(
searchText.toLowerCase()
);
この場合は、次も一致します。
'sample todo'.includes('todo');
// true
検索結果をTodoListへ渡す
検索処理で作ったfilteredTodosを、TodoListへ渡します。
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onRemove={removeTodo}
onEdit={editTodo}
/>
実装途中では、検索結果を作ったものの、元のtodosを渡したままになっていました。
<TodoList todos={todos} />
この状態では、検索処理自体が正しくても、画面には常に全件が表示されます。
正しくは、絞り込み後の配列を渡します。
<TodoList todos={filteredTodos} />
この点から、データを計算する処理だけでなく、「計算した結果をどこで使用しているか」まで確認する必要があると分かりました。
検索文字が空の場合
startsWith('')はtrueを返します。
'sample todo'.startsWith('');
// true
そのため、検索文字が空の場合はすべてのTodoが残ります。
旧版では三項演算子で、検索文字が空の場合にfilter()を実行しないよう分岐していました。
const data = searchText
? todos.filter(callback)
: todos;
新版の実装では、空文字でも全件が一致するため、特別な条件分岐を書かずに同じ結果を得られます。
const filteredTodos = todos.filter(
(todo) =>
todo.text
.toLowerCase()
.startsWith(
searchText.toLowerCase()
)
);
短いコードにすること自体が目的ではありませんが、使用するメソッドの仕様を理解すると、不要な条件分岐を減らせます。
11. Todoの編集処理を比較する
旧版の編集処理
旧版のTaskでは、Todo本文と編集モードをstateで管理していました。
this.state = {
id: this.props.id,
text: this.props.text,
editMode: false,
isDone: this.props.isDone,
};
通常時はTodo本文をspanで表示し、編集モードではinputを表示します。
const input = this.state.editMode
? (
<input
type="text"
value={this.state.text}
onChange={this.handleChangeText}
onKeyUp={this.handleKeyUpCloseEdit}
/>
)
: (
<span onClick={this.handleClickShowEdit}>
{this.state.text}
</span>
);
Todo本文をクリックすると、編集モードをtrueにします。
handleClickShowEdit() {
this.setState({
editMode: true,
});
}
入力内容が変化すると、textを更新します。
handleChangeText(event) {
this.setState({
text: event.target.value,
});
}
旧版では、Shiftキーを押しながらEnterキーを押すと編集を終了していました。
handleKeyUpCloseEdit(event) {
if (
event.keyCode === 13 &&
event.shiftKey === true
) {
this.setState({
text: event.currentTarget.value,
editMode: false,
});
}
}
新版の編集用state
新版でも、編集中の文字と編集モードはTask内で管理します。
const [editMode, setEditMode] =
useState(false);
const [text, setText] =
useState(todo.text);
役割は次のとおりです。
editMode:編集欄を表示するか
text :現在編集中の文字
setEditModeとsetTextは、自分で定義したメソッドではありません。useStateを呼び出したときにReactから返されるstate更新関数です。
const [現在値, 更新関数] =
useState(初期値);
Todo本文をクリックすると、編集モードに切り替えます。
onClick={() => setEditMode(true)}
JSXで表示を切り替える
新版では三項演算子を使い、編集モードに応じて表示を切り替えました。
{editMode ? (
<input
type="text"
value={text}
onChange={(event) =>
setText(event.target.value)
}
/>
) : (
<span
onClick={() => setEditMode(true)}
>
{todo.text}
</span>
)}
意味は次のとおりです。
editModeがtrue
→ inputを表示
editModeがfalse
→ spanを表示
三項演算子の基本形は次です。
条件 ? trueの場合 : falseの場合
JSXを複数行で記述するため、それぞれを丸括弧で囲んでいます。
制御された入力欄
編集用のinputでは、入力値をstateから受け取ります。
value={text}
入力内容が変わるたびにstateを更新します。
onChange={(event) =>
setText(event.target.value)
}
このように、表示中の値をReactのstateで管理している入力欄を、制御されたコンポーネントと呼びます。
データの流れは次のようになります。
ユーザーが入力
↓
onChangeが発生
↓
setTextでstateを更新
↓
Taskが再レンダリング
↓
新しいtextがinputへ表示される
編集を確定する
編集完了時の処理は、finishEditingへまとめました。
function finishEditing() {
const trimmedText = text.trim();
if (trimmedText) {
onEdit(todo.id, trimmedText);
} else {
setText(todo.text);
}
setEditMode(false);
}
最初にtrim()で前後の空白を取り除きます。
const trimmedText = text.trim();
文字が残っていれば、TodoのIDと編集後の文字を親へ渡します。
onEdit(todo.id, trimmedText);
空文字の場合は編集を保存せず、元のTodo本文へ戻します。
setText(todo.text);
最後に編集モードを終了します。
setEditMode(false);
onBlurで編集を確定する
編集用の入力欄には、次の指定を付けました。
onBlur={finishEditing}
onBlurは、入力欄からフォーカスが外れたときに発生するイベントです。
入力欄を選択
↓
文字を編集
↓
入力欄の外をクリック
↓
onBlurが発生
↓
編集内容を保存
似たイベントとの違いは次のとおりです。
| イベント | 発生するタイミング |
|---|---|
onFocus |
入力欄にフォーカスしたとき |
onChange |
入力内容が変わったとき |
onBlur |
入力欄からフォーカスが外れたとき |
onKeyDown |
キーを押したとき |
keyCodeからevent.keyへ
新版では、Enterキーの判定を次のようにしました。
onKeyDown={(event) => {
if (event.key === 'Enter') {
finishEditing();
}
}}
旧版のコードでは数値の13を使用していました。
event.keyCode === 13
新版では、キーの意味がそのまま分かるevent.keyを使用しています。
event.key === 'Enter'
数値だけを見るよりも、どのキーを判定しているのか読み取りやすくなりました。
Appで本文を更新する
Taskから受け取ったIDと本文を使い、AppのTodo一覧を更新します。
function editTodo(id, newText) {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id
? {
...todo,
text: newText,
}
: todo
)
);
}
IDが一致したTodoだけ、新しいオブジェクトへ置き換えます。
{
...todo,
text: newText,
}
完了状態の切り替えと同じく、元の配列やオブジェクトを直接変更しません。
12. propsとコールバック関数
親から子へデータを渡す
Reactでは、親コンポーネントから子コンポーネントへpropsを使ってデータを渡します。
AppからTodoListへは、Todo一覧を渡します。
<TodoList
todos={filteredTodos}
/>
TodoListでは、関数の引数として受け取ります。
function TodoList({ todos }) {
さらに、Todoを1件ずつTaskへ渡します。
<Task
key={todo.id}
todo={todo}
/>
Taskでは、受け取ったTodoを表示します。
function Task({ todo }) {
return <span>{todo.text}</span>;
}
データは次の方向へ流れます。
App
↓ todos
TodoList
↓ todo
Task
propsの名前は自由に決められる
propsの名前はHTMLであらかじめ決められているものではなく、コンポーネントを使用する側が決められます。
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onRemove={removeTodo}
onEdit={editTodo}
/>
ここでは、次の4つのpropsを渡しています。
todosonToggleonRemoveonEdit
ただし、名前を自由に増やせるからといって、子コンポーネントが自動的に使用するわけではありません。
子側でも、その名前で受け取る必要があります。
function TodoList({
todos,
onToggle,
onRemove,
onEdit,
}) {
子から親へ処理を通知する
Todo一覧の元データはAppが管理しています。
そのため、TaskがTodoを削除するときも、Task自身が配列を直接変更するわけではありません。
Appが削除関数を作り、子へ渡します。
function removeTodo(id) {
setTodos((currentTodos) =>
currentTodos.filter(
(todo) => todo.id !== id
)
);
}
<TodoList
onRemove={removeTodo}
/>
TodoListは、その関数をTaskへ渡します。
<Task
onRemove={onRemove}
/>
Taskは削除ボタンが押されたときに実行します。
onClick={() => onRemove(todo.id)}
処理の流れは次のとおりです。
Taskの削除ボタンをクリック
↓
onRemove(todo.id)を実行
↓
AppのremoveTodo(id)が実行される
↓
Appのtodosが更新される
↓
更新後の一覧が子へ渡される
これは「子から親へstateを直接送る」というより、親から受け取った関数を子が実行し、必要な情報を引数で親へ知らせる仕組みです。
onClick={() => ...}と書く理由
削除ボタンでは、次のようにアロー関数を渡しました。
onClick={() => onRemove(todo.id)}
次の書き方では、画面を描画した時点で関数が実行されてしまいます。
onClick={onRemove(todo.id)}
クリックされたときに実行するには、関数そのものを渡す必要があります。
引数が不要なら、次のように直接渡せます。
onBlur={finishEditing}
引数を付けて呼び出したい場合は、アロー関数で包みます。
onClick={() => onRemove(todo.id)}
propsが増えすぎた場合
今回の規模では、複数のpropsを渡しても問題ありません。
<Task
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
onEdit={onEdit}
/>
ただし、大規模なアプリで多数の階層を経由してpropsを渡し続けると、管理が難しくなります。
その場合は、次のような選択肢があります。
- コンポーネント構成を見直す
- Contextを使用する
- 状態管理ライブラリを検討する
今回のTodoアプリは小規模であり、propsだけで十分に管理できます。学習目的でReduxを追加しても、構成が複雑になる割に得られる利点は大きくありません。
13. useEffectでlocalStorageへ保存する
localStorageを追加した理由
最初の実装では、ブラウザを再読み込みするとTodoが初期状態へ戻りました。
Reactのstateは、ページを表示している間は保持されますが、再読み込みすると初期化されます。
そこで、Todo一覧をブラウザのlocalStorageへ保存しました。
localStorageは、ブラウザ内にデータを文字列として保存する仕組みです。
保存済みTodoを読み込む
Todoの初期値は、次のように定義しました。
const initialTodos = [
{
id: crypto.randomUUID(),
text: 'sample todo1',
isDone: false,
},
{
id: crypto.randomUUID(),
text: 'sample todo2',
isDone: false,
},
];
useStateの初期化時に、localStorageを確認します。
const [todos, setTodos] = useState(() => {
const savedTodos =
localStorage.getItem('todos');
if (savedTodos) {
return JSON.parse(savedTodos);
}
return initialTodos;
});
保存済みデータがある場合は、それを初期stateとして使用します。
return JSON.parse(savedTodos);
保存済みデータがない場合は、サンプルTodoを使用します。
return initialTodos;
useStateへ関数を渡している理由
次の書き方では、useStateへ値ではなく関数を渡しています。
useState(() => {
// 初期値を作る処理
});
これは遅延初期化と呼ばれる書き方です。
localStorageの読み込みは、コンポーネントが再レンダリングされるたびではなく、stateの初期化時にだけ行えば十分です。
最初の表示
→ localStorageを読み込む
その後の再レンダリング
→ 初期化処理は再実行しない
配列を文字列として保存する
localStorageへ保存できる値は文字列です。
Todo配列をそのまま保存するのではなく、JSON.stringify()で文字列へ変換します。
JSON.stringify(todos)
保存された文字列をJavaScriptの配列へ戻すときは、JSON.parse()を使います。
JSON.parse(savedTodos)
対応関係は次のとおりです。
JavaScriptの配列
↓ JSON.stringify
文字列
↓ localStorageへ保存
localStorageの文字列
↓ JSON.parse
JavaScriptの配列
useEffectでTodo変更時に保存する
Todo一覧の保存にはuseEffectを使用しました。
useEffect(() => {
localStorage.setItem(
'todos',
JSON.stringify(todos)
);
}, [todos]);
useEffectは、レンダリング後に副作用を実行するためのHookです。
今回のlocalStorageへの保存は、画面の表示内容を作る処理ではなく、React外部のブラウザ機能へデータを書き込む処理です。
そのため、useEffect内で実行しています。
依存配列[todos]の意味
useEffectの第2引数には、依存する値を配列で指定します。
[todos]
今回の意味は次のとおりです。
todosが変更された後に、この処理を実行する。
そのため、以下の操作が行われるたびにlocalStorageが更新されます。
- Todoの追加
- Todoの削除
- 完了状態の切り替え
- Todo本文の編集
検索文字searchTextはTodo本体のデータではないため、保存対象にはしていません。
localStorage利用時の注意
localStorageのデータは、同じブラウザの同じサイト内に保存されます。
そのため、次のような制約があります。
- 別の端末とは共有されない
- 別のブラウザとは共有されない
- ユーザーアカウントごとの同期はできない
- ブラウザのデータを削除すると消える
- サーバー側には保存されない
今回のような学習用Todoアプリには十分ですが、複数端末で同期するアプリにはバックエンドやデータベースが必要です。
また、localStorage内のJSONが壊れている場合、JSON.parse()でエラーになる可能性があります。本番用途ではtry...catchを使った例外処理も検討すべきです。
14. JSXの書き方を復習する
JSX内の波括弧
JSX内でJavaScriptの値や式を使用するときは、波括弧で囲みます。
<span>{todo.text}</span>
checked={todo.isDone}
onClick={() => onRemove(todo.id)}
波括弧はthisを使うための構文ではありません。
旧版でも新版でも、「JSX内へJavaScriptの式を埋め込む」ために使用します。
style={{ ... }}で波括弧が2つある理由
Reactでインラインスタイルを書くと、波括弧が2つ並びます。
style={{
textDecoration: todo.isDone
? 'line-through'
: 'none',
}}
外側の波括弧は、JSX内でJavaScriptを使用するためのものです。
style={JavaScriptの値}
内側の波括弧は、JavaScriptのオブジェクトです。
{
textDecoration: 'line-through',
}
分けて書くと、次の構造です。
style={
{
textDecoration: 'line-through',
}
}
今回の完成版では、インラインスタイルではなくCSSクラスへ移しました。
className={
todo.isDone
? 'todo-item__text todo-item__text--done'
: 'todo-item__text'
}
.todo-item__text--done {
text-decoration: line-through;
opacity: 0.65;
}
条件に応じてクラスを変更する
旧版ではclassnamesライブラリを使用していました。
const classNameLi = ClassNames({
'list__item': true,
'list__item--done': this.state.isDone,
});
このtrueはCSSに渡される値ではありません。
クラス名を文字列へ含めるかどうかを表す条件です。
true → クラス名を付ける
false → クラス名を付けない
新版では条件が単純だったため、三項演算子で表現しました。
className={
todo.isDone
? 'todo-item__text todo-item__text--done'
: 'todo-item__text'
}
条件が多くなる場合は、現在でもclassnamesなどのライブラリを使用できます。
子要素を深くインデントする理由
次のコードでは、{todo.text}がspanより深くインデントされています。
<span>
{todo.text}
</span>
todo.textがspanの子要素だからです。
span
└── todo.text
インデントは処理を動かすための必須構文ではありません。次のように1行で書いても同じです。
<span>{todo.text}</span>
複数行の場合は、親子関係を読み取りやすくするためにインデントします。
inputを/>で閉じる理由
inputは中に子要素を持たない要素です。
JSXでは、すべてのタグを閉じる必要があります。
<input />
中身を持つ要素は、開始タグと終了タグで囲みます。
<span>
{todo.text}
</span>
中身を持たない要素は、自己終了タグとして/>で閉じます。
<input />
<img />
<br />
HTMLでは<input>とだけ書ける場合がありますが、JSXでは<input />と書きます。
Reactにおけるkey
Todo一覧では、Taskにkeyを指定しています。
{todos.map((todo) => (
<Task
key={todo.id}
todo={todo}
/>
))}
keyは画面へ表示される値ではありません。
Reactが一覧内の各要素を識別するために使用します。
Todoが追加・削除・並び替えされたとき、Reactはkeyを基に「どの要素が変化したのか」を判断します。
配列の番号をkeyにすることもできますが、削除や並び替えがある一覧では、データ固有のIDを使うほうが安全です。
key={todo.id}
15. CSSとUIの改善
最初は機能だけを確認した
機能実装の途中では、ブラウザに次のような最低限の画面が表示されていました。
- タイトル
- 素の入力欄
- 素の追加ボタン
- 黒丸付きのTodo一覧
- チェックボックス
- 削除アイコン
この段階では、CSSが壊れていたわけではありません。
最初に外側のレイアウトだけを作り、各部品のCSSは後から追加していました。
.todo-app {
width: min(100% - 32px, 640px);
margin: 64px auto;
padding: 32px;
background: #fff;
border-radius: 16px;
box-shadow:
0 8px 30px rgb(0 0 0 / 8%);
}
機能とデザインを一度に実装せず、機能が正常に動くことを確認してから見た目を整えました。
コンポーネントごとのクラス設計
CSSクラスは、どの部品に属するか分かる名前にしました。
todo-app
todo-form
todo-search
todo-list
todo-item
子要素には__を付けています。
todo-app__title
todo-form__input
todo-form__button
todo-item__checkbox
todo-item__text
todo-item__delete
状態を表すクラスには--を付けました。
todo-item__text--done
厳密な設計手法の導入が目的ではありませんが、クラス名から対象と役割を判断しやすくしています。
CSS Gridによるフォーム配置
追加フォームでは、入力欄とボタンを横並びにしました。
.todo-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
display: gridは、フォーム内の要素をCSS Gridで配置する指定です。
grid-template-columns: 1fr auto;
これは横方向を2列に分割します。
| 1fr | auto |
| 入力欄 | 追加 |
-
1frは残りの幅を使用する -
autoは内容に必要な幅を使用する
そのため、入力欄は伸縮し、追加ボタンは文字に必要な幅になります。
Todo項目を横並びにする
Todo1件分もCSS Gridで配置しました。
.todo-item {
display: grid;
grid-template-columns:
auto 1fr auto;
align-items: center;
gap: 12px;
}
配置は次のようになります。
| checkbox | Todo本文 | 削除 |
| auto | 1fr | auto |
中央のTodo本文だけが残りの幅を使用します。
スマートフォン表示では、長い文字がはみ出さないようにminmax()を使用しました。
.todo-item {
grid-template-columns:
auto minmax(0, 1fr) auto;
}
完了状態のスタイル
完了したTodoにはクラスを追加します。
className={
todo.isDone
? 'todo-item__text todo-item__text--done'
: 'todo-item__text'
}
CSSでは取り消し線と透明度を指定しました。
.todo-item__text--done {
color: var(--text);
text-decoration: line-through;
opacity: 0.65;
}
完了状態はデータだけでなく、見た目からも判断できます。
Todoが0件の場合
Todoが1件もない場合は、空のulを表示するのではなく、メッセージを表示します。
if (todos.length === 0) {
return (
<p className="todo-list__empty">
該当するTodoはありません
</p>
);
}
検索結果が0件の場合にも同じ表示になります。
.todo-list__empty {
margin: 24px 0 0;
padding: 24px;
text-align: center;
border: 1px dashed var(--border);
border-radius: 10px;
}
アイコンボタンのアクセシビリティ
削除操作には、Lucide Reactのゴミ箱アイコンを使用しました。
import { Trash2 } from 'lucide-react';
<button
className="todo-item__delete"
type="button"
onClick={() => onRemove(todo.id)}
aria-label={`${todo.text}を削除`}
>
<Trash2
size={18}
aria-hidden="true"
/>
</button>
見た目だけならiタグでもクリック処理を付けられます。
しかし、操作できる要素にはbuttonを使用したほうが、キーボード操作や読み上げに対応しやすくなります。
アイコン自体は装飾として読み上げ対象から外します。
aria-hidden="true"
ボタンの目的はaria-labelで伝えます。
aria-label={`${todo.text}を削除`}
レスポンシブ対応
画面幅が600px以下の場合は、余白を小さくし、追加フォームを縦並びにします。
@media (max-width: 600px) {
.todo-app {
margin: 24px auto;
padding: 20px;
}
.todo-form {
grid-template-columns: 1fr;
}
.todo-form__button {
width: 100%;
}
.todo-item {
grid-template-columns:
auto minmax(0, 1fr) auto;
gap: 8px;
}
.todo-item__text,
.todo-item__edit {
min-width: 0;
overflow-wrap: anywhere;
}
}
機能が動くだけでなく、小さい画面でも操作できる状態を目指しました。
16. 作り直して分かった旧版と新版の違い
コード量が減った
クラスコンポーネントでは、stateを使うために次の記述が必要でした。
class TodoApp extends React.Component {
constructor(props) {
super(props);
this.state = {
data: [],
};
this.addTodo =
this.addTodo.bind(this);
}
render() {
return <div />;
}
}
関数コンポーネントでは、必要な状態をuseStateで定義できます。
function App() {
const [todos, setTodos] =
useState([]);
return <main />;
}
constructor、super(props)、render()、bind(this)が不要になり、実際の処理へ集中しやすくなりました。
変わったのは文法だけではない
クラスから関数へ書き換えるだけなら、表面的な変換で終わります。
今回、特に重要だったのはstateの管理方法です。
旧版では、親と子の両方がisDoneを持っていました。また、配列やオブジェクトを直接変更する処理もありました。
新版では、Todo一覧の正しい状態をAppへ集めました。
App
└── todosを管理
Task
└── 受け取ったtodoを表示
さらに、map()やfilter()を使って新しい配列を作り、stateを直接変更しないようにしました。
現在も変わらない基本
書き方が変わっても、次の考え方は旧版と共通しています。
- UIをコンポーネントへ分割する
- 親から子へpropsを渡す
- ユーザー操作をイベントで受け取る
- stateに応じて表示を変える
- 配列から一覧を生成する
- 各要素へ一意な
keyを付ける
そのため、旧版の学習がすべて無駄になったわけではありません。
クラスコンポーネントの知識は既存コードを読む際に役立ちます。一方、新規実装では関数コンポーネントとHooksを使えるようにしておく必要があります。
開発環境の違いも大きかった
旧版では、Node.jsのバージョン、Gulp、Webpack、Babel、BrowserSyncの組み合わせを意識する必要がありました。
新版では、Viteによって開発サーバーとビルド環境を短時間で用意できました。
旧版
コード変更
→ Gulpでビルド
→ bundle.jsを生成
→ 手動で再読み込み
新版
コード変更
→ 保存
→ 自動反映
React本体の変化だけでなく、周辺ツールの改善によって開発体験も大きく変わったと感じました。
新旧比較
| 項目 | 旧版 | 新版 |
|---|---|---|
| コンポーネント | クラス | 関数 |
| 状態管理 | this.state |
useState |
| 状態更新 | this.setState() |
setTodos()など |
| イベントメソッド |
bind(this)が必要 |
bind不要 |
| 表示処理 | render() |
関数のreturn
|
| 配列追加 | push() |
スプレッド構文 |
| 完了切り替え | splice() |
map() |
| 削除 | Lodash reject()
|
filter() |
| 検索 | RegExp |
startsWith() |
| キー判定 | keyCode |
event.key |
| 永続化 | なし |
useEffectとlocalStorage |
| 開発環境 | Gulpなど | Vite |
17. まとめ
今回、React 16系のクラスコンポーネントで作られたTodoアプリを一度写経し、その後、同じ機能を関数コンポーネントとHooksで作り直しました。
実装した機能は次のとおりです。
- Todoの追加
- Todoの一覧表示
- 完了状態の切り替え
- Todoの削除
- Todo本文の編集
- Todoの検索
- localStorageへの保存
- 空の一覧表示
- レスポンシブ対応
復習を通じて、特に次の点を整理できました。
-
render()に相当する役割を関数コンポーネント本体が担う -
useStateは現在値と更新関数を返す - state更新関数へ関数を渡すと、更新直前の値を受け取れる
- 関数コンポーネントでは
thisを使わないためbindも不要 -
map()は要素を1件ずつ変換して新しい配列を返す -
filter()は条件を満たした要素だけを残す - propsは親から子へ渡される
- 子は親から渡された関数を実行して操作を通知する
- stateは直接変更せず、新しい配列やオブジェクトを作る
-
useEffectでReact外部の仕組みと同期できる
Todoアプリ自体は定番の題材です。そのため、このアプリをポートフォリオの中心にするのではなく、旧Reactから現在のReactへ知識を更新するための教材として位置付けました。
重要だったのは完成コードを暗記することではなく、コードを分解し、次の疑問を一つずつ解消したことです。
- この値はどこから渡されるのか
- どのコンポーネントがstateを持つべきか
- なぜこの関数が実行されるのか
- なぜ元の配列を直接変更しないのか
- 更新された値がどう画面へ反映されるのか
今後、既存のクラスコンポーネントを読むときも、関数コンポーネントで新しく実装するときも、今回整理したデータと処理の流れを基準に考えていきます。
18. 今後の学習方針
Todoアプリへ機能を増やし続ける気はない
Todoアプリには、認証、API、ドラッグ&ドロップ、期限管理など、さまざまな機能を追加できます。
しかし、今回の目的はReactの基礎を現代的な書き方で復習することでした。
次の内容はすでに一通り実装できています。
- state管理
- props
- イベント処理
- フォーム
- 条件分岐
- 一覧表示
- コンポーネント分割
- 副作用
- ブラウザへのデータ保存
そのため、Todoアプリへ無理に機能を追加し続けるより、次の題材へ進むほうが学習効果は高いと考えています。
Reduxは今回使用しなかった
旧教材にはReduxを扱う内容もありました。
ただし、今回のTodoアプリでは、状態を管理する中心がAppにまとまっています。
App
├── TodoCreator
├── Search
└── TodoList
└── Task
propsの受け渡しも数階層に収まっており、Reduxを導入しなくても十分に管理できます。
小規模なアプリへ状態管理ライブラリを追加すると、ファイルや概念が増え、かえって処理を追いにくくなる場合があります。
まずはReact標準の機能で状態管理を理解し、次のような問題が実際に発生してから導入を検討します。
- 多数のコンポーネントで同じ状態を共有する
- propsの受け渡しが何階層にも続く
- 状態更新の種類が増えて管理しにくい
- サーバーデータと画面状態の管理が複雑になる
Reduxを学ぶ場合も、旧式の書き方をそのまま覚え直すのではなく、現在一般的なRedux Toolkitを使って小さく試す予定です。
次は実際の課題を解決するアプリへ進む
TodoアプリはReactの基礎確認には適していますが、ポートフォリオとしては題材が一般的です。
次のアプリでは、単にCRUD機能があるだけでなく、次の点を説明できる題材に進みます。
- 誰のどのような問題を解決するのか
- なぜその機能が必要なのか
- どのようにデータを設計したのか
- 認証や権限をどう扱うのか
- APIやバックエンドとどう連携するのか
- エラーや通信中の状態をどう表示するのか
- テストや保守性をどう考えたのか
今回のTodoアプリは、その次のアプリを作るための基礎確認としてGitHubへ残します。
追加で改善するなら
学習用アプリとしてさらに改善する場合は、次の候補があります。
- TypeScriptへの移行
- React Testing Libraryによるテスト
- localStorage読み込み時の例外処理
- 編集キャンセル機能
- 完了済みTodoの一括削除
- フィルター機能
- 作成日時や期限の追加
- キーボード操作の改善
- アクセシビリティの確認
- ESLintによるコード品質チェック
ただ、ひとまずは今回で本ToDoアプリは完了とし、TypeScriptの学習にいこうとおもいます。