React vs JavaScript ~Todoアプリ開発で比較してみた~

こんにちは!KENTEM 第2開発部のフロントエンドエンジニアM・Sです。

KENTEM では、React を用いてフロントエンドを開発しています。初めのうちは React の独特な書き方に抵抗を感じていましたが、今では React なしで開発することなんて考えられません。

そこで今回は、あえて React を使わずに Todoアプリを作ることで React の魅力とありがたみを再認識してみたいと思います!

今回作るもの

以下のような簡単な Todoアプリを作ってみます。

アプリの基本的な流れ。要素の追加・編集・削除が可能。

スタイルは tailwind を使って Gemini君が書いてくれました。

それぞれのアイテムは編集と削除が可能になっています。

実装

どちらもあえて1ファイルで実装します。

コードは長めですが、雰囲気だけでも掴んでいただければ幸いです。

純粋なJavaScript

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todoリスト</title>
  <script src="https://cdn.tailwindcss.com" rel="stylesheet"></script>
</head>
<body>
  <div class="container mx-auto p-4 max-w-md">
    <h1 class="text-3xl font-bold mb-4">Todoリスト</h1>

    <form id="todo-form" class="flex mb-4">
      <input
        type="text"
        id="todo-input"
        placeholder="Todoを入力"
        class="border border-gray-400 p-2 rounded-l-md flex-grow focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      <button
        type="submit"
        class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r-md"
      >
        登録
      </button>
    </form>

    <ul id="todo-list" class="space-y-2"></ul>
  </div>

  <script>
    const todoList = document.getElementById('todo-list');
    const todoForm = document.getElementById('todo-form');
    const todoInput = document.getElementById('todo-input');
    let todos = [];

    const renderTodoItem = (todo, index) => {
      const listItem = document.createElement('li');
      listItem.classList.add('flex', 'items-center', 'bg-white', 'shadow-inner', 'drop-shadow-xl', 'rounded', 'p-2');

      if (todo.editing) {
        // 編集中の場合はinputを表示
        const input = document.createElement('input');
        input.type = 'text';
        input.value = todo.text;
        input.classList.add('flex-grow', 'mr-2', 'border', 'border-gray-400', 'p-2', 'rounded', 'focus:outline-none', 'focus:ring-2', 'focus:ring-blue-500');
        input.addEventListener('blur', () => {
          updateTodo(index, input.value);
        });
        input.addEventListener('keydown', (event) => {
          if (event.key === 'Enter') {
            updateTodo(index, input.value);
          }
        });
        listItem.appendChild(input);
      } else {
        // 編集中でない場合はspanを表示
        const todoSpan = document.createElement('span');
        todoSpan.textContent = todo.text;
        todoSpan.classList.add('flex-1', 'px-2', 'py-1', 'cursor-pointer', 'text-gray-800', 'hover:text-gray-600', 'rounded');
        todoSpan.addEventListener('click', () => {
          editTodo(index);
        });
        listItem.appendChild(todoSpan);
      }

      const deleteButton = document.createElement('button');
      deleteButton.textContent = '削除';
      deleteButton.classList.add('ml-2', 'px-2', 'py-1', 'text-sm', 'font-medium', 'text-red-500', 'hover:text-red-700');
      deleteButton.addEventListener('click', () => deleteTodo(index));
      listItem.appendChild(deleteButton);

      return listItem;
    };

    const renderTodos = () => {
      todoList.innerHTML = '';
      todos.forEach((todo, index) => {
        const listItem = renderTodoItem(todo, index);
        todoList.appendChild(listItem);
      });
    };

    const addTodo = (event) => {
      event.preventDefault();
      const newTodoText = todoInput.value;
      if (newTodoText !== '') {
        todos.push({ text: newTodoText, editing: false });
        todoInput.value = '';
        renderTodos();
      }
    };

    const deleteTodo = (index) => {
      todos.splice(index, 1);
      renderTodos();
    };

    const editTodo = (index) => {
      todos[index].editing = true;
      renderTodos();
      // 入力欄にフォーカスを当てる
      const input = todoList.children[index].querySelector('input');
      input.focus();
    };

    const updateTodo = (index, newText) => {
      todos[index].text = newText;
      todos[index].editing = false;
      renderTodos();
    };

    todoForm.addEventListener('submit', addTodo);
    renderTodos();
  </script>
</body>
</html>

読むのが億劫な方も多いのではないでしょうか...

解説

初期表示

  • 変数 todoList, todoForm, todoInput, todos が初期化される
  • todoForm が submit された際に addTodo が発火することを指定
  • renderTodos が呼び出される(初回は何も描画されない)

登録処理

  1. addEventListener により addTodo が呼び出される
  2. input にテキストを入力して submit すると addTodo が呼び出される
  3. テキストが空白でなければ todos に push して再度 renderTodos を呼び出す

描画処理

  1. renderTodos は todos の要素数の数だけ renderTodo を呼び出す
  2. renderTodo は li要素を作る
    1. 編集中の場合
      • li に input要素と value・イベントを追加する
    2. 編集中でない場合
      • li に span要素と todo の text を埋め込む
        • クリックされたら編集中になるようにイベントを追加する
    3. 削除ボタンと削除イベントの定義も renderTodo で行う

削除処理

  • index を取得し、deleteTodo で splice を使って削除

編集処理

  • 指定された index の todo の editing を true にする
  • renderTodos を呼び出し、すべての todo を再描画する
  • input にフォーカスを当てる
  • 完了したら updateTodo を呼び出し上書きしたのちに renderTodos をもう一度呼び出す

所感

めんどくさい!!!!

後半はAIに手伝ってもらった部分もあるとはいえ、JavaScript単体だと辛いものがありました。手続型のプログラミングではどうしてもコードが複雑化しやすいですね。特にDOM操作が絡むと、要素の取得、イベントリスナーの設定、状態の更新など、処理が散らばってしまいました。結果として、可読性や保守性が損なわれているコードになってしまいました。

続いてお待ちかねReactです。

React

import React, { useState } from "react";
import { v4 as uuid } from "uuid";

const App = () => {
  const [input, setInput] = useState("");
  const [todos, setTodos] = useState([]);

  const handleDelete = (index) => {
    setTodos((prevTodos) => prevTodos.filter((_, i) => i !== index));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (input.trim() === "") return;
    setTodos((t) => [...t, input]);
    setInput("");
  };

  return (
    <div className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-4">Todoリスト</h1>
      <form onSubmit={handleSubmit} className="flex mb-4">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Todoを入力"
          className="border border-gray-400 p-2 rounded-l-md flex-grow focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r-md"
        >
          登録
        </button>
      </form>
      <ul className="space-y-2">
        {todos.map((todo, i) => (
          <li className="flex items-center bg-white shadow-inner drop-shadow-xl rounded p-2">
            <Item
              key={uuid()}
              todo={todo}
              onEdit={(updatedTodo) => {
                setTodos((prevTodos) =>
                  prevTodos.map((t, j) => (j === i ? updatedTodo : t))
                );
              }}
            />
            <button
              onClick={() => handleDelete(i)}
              className="ml-2 px-2 py-1 text-sm font-medium text-red-500 hover:text-red-700"
            >
              削除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

const Item = ({ todo, onEdit }) => {
  const [edit, setEdit] = useState(false);
  const [text, setText] = useState(todo);

  const handleKeyDown = (e) => {
    if (e.key === "Enter") {
      setEdit(false);
      onEdit(text);
    }
  };

  return (
    <>
      {edit ? (
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={handleKeyDown}
          onBlur={() => {
            setEdit(false);
            onEdit(text);
          }}
          className="flex-grow mr-2 border border-gray-400 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      ) : (
        <span
          onClick={() => setEdit(true)}
          className="flex-1 px-2 py-1 cursor-pointer text-gray-800 hover:text-gray-600 rounded"
        >
          {text}
        </span>
      )}
    </>
  );
};

export default App;

解説

初期表示

  • todos と input の useState を初期化
  • App コンポーネントと Item コンポーネントが読み込まれる

登録処理

  • フォームに入力されたテキストを 状態input に保持。
  • handleSubmit 関数が実行され、todos に値が追加される
  • useState が更新されることでレンダリングが行われる

描画処理

  • useState が更新されるたびにレンダリングが行われる

削除処理

  • handleDelete 関数が実行され、todos 状態から該当Todoを削除
  • useStateが更新されるのでレンダリングは自動で行われる

編集処理

  • Item コンポーネントの状態editがtrueに切り替わる
  • Item コンポーネントがspanからinputに切り替わる
  • フォーカスが外れるとeditがfalseに切り替わりspanに戻る
  • useStateが更新されるのでレンダリングは自動で行われる

所感

読みやすい!!!!!

useState を使うことで、DOM操作を意識することなく状態管理ができ、更新も容易に行えています。useStateが更新された際に再レンダリングされるので、描画について意識する必要はほぼありません(仮想DOM についてはまたどこかで)。また、Item をコンポーネントとして切り分けることで、ロジックと JSX の役割が明確になり、可読性や保守性を高めることができています。

まとめ

今回は、あえて React を使わずにシンプルな Todoアプリを作成することで、React を使うメリットを改めて実感することができました。

純粋な JavaScript で実装した Todoアプリは、DOM 操作やイベント処理が複雑になり、コードの可読性や保守性の面で課題が残りました。

一方、React を使った実装では、以下のような点が優れていました。

  • 宣言的なUI記述による状態管理の容易さ:React では、useState などを用いることで、状態と UI の同期を宣言的に記述できます。そのため、DOM操作を直接行う必要がなくなり、状態の変化が UI にどのように反映されるかを容易に管理できます。
  • コンポーネントベース開発:Itemコンポーネントのように、UI を独立したコンポーネントに分割することで、コードの再利用性や保守性を高めることができました。また、コンポーネントごとに状態やロジックをカプセル化することで、コード全体の複雑さを軽減できました。

これらの特徴により、React を使うことでより可読性・保守性・拡張性が高いアプリケーション開発が可能になります。

今回の執筆を通して、React の魅力とありがたみを再認識することができました! これからもReact を使い続けながら、理解を深め、さらに快適で効率的な開発を目指していきたいと思います!

KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
recruit.kentem.jp career.kentem.jp