JavaScriptとReactの学習として、それぞれでTodoアプリを作りました。
追加・完了・削除・未完了に戻す処理を実装する中で、Reactでは画面の構造や更新処理を書きやすいと感じました。
この記事では、その理由を実際のコードを比較しながら整理します。なお、ReactもJavaScriptのライブラリなので、ここではReactを使わない実装を「JavaScript版」と呼びます。
1. 表示する構造をそのまま書ける、DOMを自分で組み立てなくていい
例えば、「Todoの文字と完了・削除ボタンが並んだ1行」を表示する場合です。
JavaScript版では、要素を作り、文字やクラスを設定して、親子関係を組み立てています。次は実装からイベント登録などを省略した抜粋です。
const li = document.createElement("li");
const div = document.createElement("div");
div.className = "list-row";
const p = document.createElement("p");
p.className = "todo-items";
p.innerText = todo;
div.appendChild(p);
li.appendChild(div);
const completeButton = document.createElement("button");
completeButton.innerText = "完了";
const deleteButton = document.createElement("button");
deleteButton.innerText = "削除";
div.appendChild(completeButton);
div.appendChild(deleteButton);
document.getElementById("incompleted-list").appendChild(li);
この書き方では、要素を作る処理と追加する処理を追いながら、親子関係を頭の中で組み立てる必要があり、私は読み取りにくく感じました。さらに、クリックしたときの処理を結び付けるには、completeButton.addEventListener(...)などのイベント登録も必要です。
React版では、同じような1行を次のJSXで表しています。これは一覧を作るmapの中の記述です。
<li key={todo}>
<div className="list-row">
<p>{todo}</p>
<button onClick={() => onClickCompleteTodo(index)}>完了</button>
<button onClick={() => onClickDeleteTodo(index)}>削除</button>
</div>
</li>
liの中にdivがあり、その中に文字と2つのボタンが並ぶことを、コードの見た目から読み取れます。クリック時の処理も、それぞれのボタンに指定できます。
要素を組み立てる手順を追う負担が減り、表示したい構造を読み取りやすくなったことが便利でした。
2. コンポーネント化して、親のJSXは画面全体の構成を書ける
例えば、入力エリアを画面に置く場合です。
JavaScript版では、HTMLに入力欄とボタンを書いています。
<div class="input-area">
<input id="add-text" placeholder="TODOを入力">
<button id="add-button">追加</button>
</div>
動作はJavaScript側で、IDを使って要素を取得して結び付けています。入力欄の値をどこで取得しているか、ボタンにどの処理を登録しているかを確認するために、HTMLとJavaScriptを行き来する必要がありました。
const onClickAdd = () => {
const inputText = document.getElementById("add-text").value;
document.getElementById("add-text").value = "";
createIncompleteList(inputText);
};
document.getElementById("add-button").addEventListener("click", onClickAdd);
React版では、入力欄とボタンの詳細をコンポーネントにまとめ、親には次のように書いています。
<InputTodo
todoText={todoText}
onChange={onChangeSetTodoText}
onClickAdd={onClickAddTodo}
disabled={checkDisabled()}
/>
InputTodoという名前で入力用のコンポーネントだと分かり、propsを見ると、入力値・入力時の処理・追加時の処理・無効化する条件を受け渡していることが分かります。
入力欄やボタンなどの詳細を毎回読む必要がなく、画面全体では部品の配置を、各コンポーネントでは部品の中身を考えられることが便利でした。
親には引き続きstateやイベント処理を書いていますが、表示する部分をコンポーネントに分けることで、returnの部分を整理できました。
import/exportはJavaScriptでも使える機能なので、ここではUIを名前の付いた部品として組み合わせられる点に便利さを感じています。
参考:JavaScript版のHTML、JavaScript版の処理、React版の親コンポーネント
3. データを更新すると、その内容が画面に反映される
特に違いを感じたのは、Todoを完了させる処理です。
JavaScript版では、完了ボタンを押したときに、次のようなDOM操作をしています。
- 対象の
liを探す - 削除ボタンと完了ボタンを取り除く
- 戻すボタンを作る
- 完了一覧へ
liを移動する - 件数表示を更新する
次は完了時の処理から、DOM操作の部分を抜粋したものです。
const moveTarget = completeButton.closest("li");
completeButton.nextElementSibling.remove();
completeButton.remove();
const backButton = document.createElement("button");
backButton.innerText = "戻す";
moveTarget.firstElementChild.appendChild(backButton);
document.getElementById("completed-list").appendChild(moveTarget);
この処理を読むには、「完了ボタンの次に削除ボタンがある」というDOMの並びも把握する必要があります。
一方、React版の完了処理は、配列の更新として書いています。
const onClickCompleteTodo = (index) => {
const newIncompleteTodos = [...incompleteTodos];
newIncompleteTodos.splice(index, 1);
const newCompleteTodos = [
...completeTodos,
incompleteTodos[index],
];
setIncompleteTodos(newIncompleteTodos);
setCompleteTodos(newCompleteTodos);
};
ここで行っているのは、「未完了の配列から対象のTodoを取り除き、完了の配列に追加する」という処理です。それぞれの新しい配列をsetterに渡して、stateを更新します。
完了ボタンを押してから表示が変わるまでの流れは、次のようになります。
-
onClickCompleteTodo(index)が実行される - 未完了・完了それぞれの新しい配列を作り、stateを更新する
- Reactが更新後のstateをもとに、各一覧の表示内容を再計算する
- 必要な変更がDOMに反映され、対象のTodoが完了一覧に表示される
各一覧では、配列の各Todoをmapで処理し、表示する要素をJSXで記述しています。完了一覧には戻すボタンを表示する、とあらかじめ書いてあるので、イベント処理の中で完了・削除ボタンを消して戻すボタンを作る必要がありません。
操作後にデータがどう変わるかに集中できたことが、Reactを便利だと感じた大きな理由です。
JavaScript版を書いているときは、「ボタンを一つずつ消したり作ったりするより、変更後のデータに合わせて表示し直せれば楽なのに」と感じました。Reactでは、stateを更新して、そのstateに応じた表示を定義することで、この負担を減らせました。
ここで、コンポーネントはUIを部品に分ける仕組みで、更新を扱いやすくしているのはstateとレンダリングの仕組みです。再レンダリングのたびに画面全体のDOMを作り直すわけではなく、Reactが表示内容を再計算し、必要な変更をDOMに反映します。
件数表示も、同じ配列から求められる
JavaScript版では、一覧のDOMから件数を数えて、表示を書き換えています。
const updateIncompleteCount = () => {
const count = document.getElementById("incompleted-list").childElementCount;
document.getElementById("incompleted-count").innerText = `残り: ${count}`;
};
この方法では、一覧を変更したあとに件数表示の更新処理を呼ぶ必要があります。実際に今回のコードでは、削除処理からこの関数を呼んでいません。
React版に同じ件数表示を追加するなら、未完了一覧のコンポーネント内に次のように書けます。
<p>残り: {incompleteTodos.length}</p>
一覧も件数も、同じincompleteTodosから求められます。stateを更新すると一覧と件数の両方がその値に基づいて再計算されるので、操作ごとに件数表示を更新する関数を呼ばずに済みます。
参考:JavaScript版のDOM操作、React版のstate更新、React公式:Render and Commit
4. イベント処理を分けて、ボタンとの対応を書ける
JavaScript版では、完了ボタンのイベント処理の中で戻すボタンを作り、さらに戻すボタンのイベントを登録しています。
構造を抜き出すと、次のようになっています。
completeButton.addEventListener("click", () => {
// 戻すボタンを作る処理など
backButton.addEventListener("click", () => {
// 未完了に戻す処理
});
});
一方、React版では、完了処理と戻す処理をTodo.jsx内の別々の関数として定義しています。
ボタン側では、次のようにそれぞれの処理を結び付けています。2つのコードは、未完了一覧と完了一覧からそれぞれ抜粋したものです。
<button onClick={() => onClickCompleteTodo(index)}>
完了
</button>
<button onClick={() => onClickBackTodo(index)}>
戻す
</button>
「ボタンを作ってからイベントを登録する」という手順をイベント処理の中に書かずに済み、ボタンの表示とクリック時の動作の対応を読み取りやすくなりました。
JavaScriptでもイベント処理を別の関数に切り出すことはできます。今回のReact版では、stateを更新する処理を親で定義し、子コンポーネントに渡しました。子では、受け取った関数をボタンのonClickに指定しています。データを更新する処理と表示を分けつつ、どのボタンでどの処理が動くかを読み取りやすくできたことが便利でした。
JSXにイベントハンドラーを指定する形が、今回の処理を整理するうえで分かりやすかったことです。
参考:JavaScript版のイベント登録、React版のボタン
作ってみて感じたこと
両方を作ってみて、JavaScript版では要素の生成・削除・移動を自分で管理し、React版ではstateとJSXを使って画面を表現する違いを体験できました。
コンポーネント化して、親のJSXは基本Importの構成で作り、コードの可読性や拡張性を高めていきたいです。

