1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vite + React で Todoアプリを作った

Posted at

はじめに

こんにちは!
Reactを学び始めてもうすぐ3週間です!
今回はViteで初期セットアップをして
ReactでTodoアプリを作ったのでアウトプットしていきます!

以降はGitHubアカウント、リポジトリがある前提で話を進めます。

初期セットアップ

以下のコマンドを叩いて初期セットアップをしていきます

npm create vite@latest

詳細は私が書いた以下の記事にあるのでご覧ください

デザイン

今回は初期セットアップのデフォルトで記載されているCSSファイルとTailwind CSSにてスタイルを当てていきます。

初期セットアップが終了したら以下のコマンドでTailwind CSSをインストールしていきます。

npm install tailwindcss @tailwindcss/vite

インストールが完了したら、vite.config.jsに以下のコードを記述します。

 import { defineConfig } from 'vite'
+import tailwindcss from '@tailwindcss/vite'
 import react from '@vitejs/plugin-react'

// https://vite.dev/config/
 export default defineConfig({
   base: '/react_todo_list',
   plugins: [
     react(),
+    tailwindcss(),
   ],
 })

今回のTodoアプリの仕様

Create

  • 入力フィールドと追加のボタンがある
  • 入力フィールドに「仕事」のような文字を入力して追加ボタンを押すとTodo一覧に表示される

Read

  • Todo一覧が表示されている
  • 仕事(編集)(削除)のように表示される
  • 編集と削除はボタン
  • チェックボックスでタスクの完了と未完了がわかる
  • 全てのタスク:3 完了済み:2 未完了:1
    上記ようにタスクの数を表示する
  • チェックボックスにチェックをいれて
    タスクを完了させることによりタスクの数はリアルタイムで変わる
  • タスクを削除するとタスクの数は減り、新規作成するとタスクの数は増える

Update

  • 編集ボタンを押すと文字がフォームに変わってその場で編集できる
  • [仕事](戻る)(更新)と表示され保存すると変更した文字にタイトルが変わる
  • 任意のタスクのタイトル変更時、別のタスクの状態を変更することはできない

Delete

削除ボタンを押すと「本当によろしいですか?」と表示されOKを押すと削除できる

完成イメージ画像

スクリーンショット 2025-07-18 22.49.51.png

実装部分

このセクションは汚いコードの作成から始まります。
最終的なコードがどうなったか知りたい方はコンポーネント+リファクタリング

以下の順序で書いていきます。

  • Create
  • Read
  • Update
  • Delete
  • コンポーネント化
  • 初期セットアップ完了後のSVG等の不要なコード、細かいCSSは省略して書いていきます

Create

状態管理

const [input, setInput] = useState('');
const nextId = useRef(1);

処理関数

const addTodoText = (event) => setInput(event.target.value);

const addTodo = () => {
  if (input.trim()) {
    setTodos([...todos, {
      id: nextId.current++,
      text: input,
      completed: false,
    }])
    setInput('');
  }
}

UI部分

<div className='flex mb-8 gap-x-2'>
  <input type="text" className='flex-1 border-2 border-white rounded-xl p-2' value={input} onChange={addTodoText} name='todo-input' />
  <button onClick={addTodo} className='bg-blue-500'>追加</button>
</div>

Read

状態管理

const [todos, setTodos] = useState([]);

統計関数

const completedTodos = (todos) => todos.filter(todo => todo.completed).length;
const incompletedTodos = (todos) => todos.filter(todo => !todo.completed).length;

UI部分(通常モード)

<h1 className='mb-8'>Todo App</h1>

<div className='mb-8'>
  <p>全てのタスク: {todos.length} / 完了済み: {completedTodos(todos)} / 未完了: {incompletedTodos(todos)}</p>
</div>

{/* リスト表示部分 */}
<div>
  <ul>
  {todos.map((todo) => {
    <li key={todo.id} className={`grid grid-cols-12 items-center gap-x-2 mb-4 ${isEditing ? 'opacity-50' : ''}`}>
      <input 
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(todo.id)}
        className='col-span-1'
        disabled={isEditing}
      />
      <p className={`col-span-5 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
        {todo.text}
      </p>
      <button 
        onClick={() => editTodo(todo)} 
        className='col-span-3 bg-zinc-600'
        disabled={isEditing}
      >
        編集
      </button>
      <button 
        onClick={() => setTodos(todos.filter((t) => t.id !== todo.id))} 
        className='col-span-3 bg-red-600'
        disabled={isEditing}
      >
        削除
      </button>
    </li>
  })}
  </ul>
</div>

Update

状態管理

const [isEditing, setIsEditing] = useState(false);
const [currentTodo, setCurrentTodo] = useState(null);

処理関数

const editTodo = (todo) => {
  setIsEditing(true);
  setCurrentTodo({...todo});
}

const cancelEdit = () => {
  setIsEditing(false);
  setCurrentTodo(null);
};

const updateTodos = (todo) => {
  if (currentTodo && currentTodo.text.trim()) {
    setTodos(todos.map(todo =>
      todo.id === currentTodo.id ? currentTodo : todo
    ));
    setIsEditing(false);
    setCurrentTodo(null);
  }
}

const toggleTodo = (todoId) => {
  setTodos(prevTodos =>
    prevTodos.map(t =>
      t.id === todoId ? { ...t, completed: !t.completed } : t
    )
  );
}

UI部分(編集モード)

{todos.map((todo) => {
  if (isEditing && currentTodo && currentTodo.id === todo.id) {
    return (
      <li key={todo.id} className='grid grid-cols-12 items-center gap-x-2 mb-4'>
        <input 
          className='col-span-6 border-2 border-white rounded-xl p-2'
          value={currentTodo.text}
          onChange={(e) => setCurrentTodo({...currentTodo, text: e.target.value})}
        />
        <button onClick={() => cancelEdit()} className='col-span-3 bg-zinc-600'>戻る</button>
        <button onClick={() => updateTodos()} className='col-span-3 bg-emerald-600'>更新</button>
      </li>
    );
  }
  // 通常モードは省略
})}

Delete

処理関数

const deleteTodo = (todoId) => {
  const message = '本当によろしいですか?';
  if (confirm(message))  {
    setTodos(todos.filter(todo => todo.id !== todoId));
  }
}

UI部分

// ...
<button 
  onClick={() => deleteTodo(todo.id)} 
  className='col-span-3 bg-red-600'
  disabled={isEditing}
>
  削除
</button>
// ...

殴り書きの結果

import { useRef, useState } from 'react'
import './App.css'

function App() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');
  const [isEditing, setIsEditing] = useState(false);
  const [currentTodo, setCurrentTodo] = useState(null);
  const nextId = useRef(1);

  const addTodoText = (event) => setInput(event.target.value);

  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, {
        id: nextId.current++,
        text: input,
        completed: false,
      }])
      setInput('');
    }
  }

  const toggleTodo = (todoId) => {
    setTodos(prevTodos =>
      prevTodos.map(t =>
        t.id === todoId ? { ...t, completed: !t.completed } : t
      )
    );
  }

  const completedTodos = (todos) => todos.filter(todo => todo.completed).length;
  const incompletedTodos = (todos) => todos.filter(todo => !todo.completed).length;

  const editTodo = (todo) => {
    setIsEditing(true);
    setCurrentTodo({...todo});
  }

  const cancelEdit = () => {
    setIsEditing(false);
    setCurrentTodo(null);
  };

  const updateTodos = (todo) => {
    if (currentTodo && currentTodo.text.trim()) {
      setTodos(todos.map(todo =>
        todo.id === currentTodo.id ? currentTodo : todo
      ));
      setIsEditing(false);
      setCurrentTodo(null);
    }
  }

  const deleteTodo = (todoId) => {
    const message = '本当によろしいですか?';
    if (confirm(message))  {
      setTodos(todos.filter(todo => todo.id !== todoId));
    }
  }

  return (
    <>
      <h1 className='mb-8'>Todo App</h1>
      <div className='flex mb-8 gap-x-2'>
        <input type="text" className='flex-1 border-2 border-white rounded-xl p-2' value={input} onChange={addTodoText} name='todo-input' />
        <button onClick={addTodo} className='bg-blue-500'>追加</button>
      </div>
      <div className='mb-8'>
        <p>全てのタスク: {todos.length} / 完了済み: {completedTodos(todos)} / 未完了: {incompletedTodos(todos)}</p>
      </div>
      <div>
        <ul>
        {todos.map((todo) => {
          if (isEditing && currentTodo && currentTodo.id === todo.id) {
            return (
              <li key={todo.id} className='grid grid-cols-12 items-center gap-x-2 mb-4'>
                <input 
                  className='col-span-6 border-2 border-white rounded-xl p-2'
                  value={currentTodo.text}
                  onChange={(e) => setCurrentTodo({...currentTodo, text: e.target.value})}
                />
                <button onClick={() => cancelEdit()} className='col-span-3 bg-zinc-600'>戻る</button>
                <button onClick={() => updateTodos()} className='col-span-3 bg-emerald-600'>更新</button>
              </li>
            );
          }
          
          return (
            <li key={todo.id} className={`grid grid-cols-12 items-center gap-x-2 mb-4 ${isEditing ? 'opacity-50' : ''}`}>
              <input 
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
                className='col-span-1'
                disabled={isEditing}
              />
              <p className={`col-span-5 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
                {todo.text}
              </p>
              <button 
                onClick={() => editTodo(todo)} 
                className='col-span-3 bg-zinc-600'
                disabled={isEditing}
              >
                編集
              </button>
              <button 
                onClick={() => deleteTodo(todo.id)} 
                className='col-span-3 bg-red-600'
                disabled={isEditing}
              >
                削除
              </button>
            </li>
          );
        })}
        </ul>
      </div>
    </>
  )
}

export default App

いや〜、これぞ殴り書き!ですね。
次からコンポーネントに分割していきますよ。
最終的に App.jsx は殴り書きの半分くらいの100行程度になります。

コンポーネント+リファクタリング

以下のコンポーネントを作成とリファクタリングをしていきます。
今回は小規模な実装な為、componentsフォルダを作成して src/components 配下に全てのコンポーネント作成します。

リファクタリングでは以下のことをします。

  • タスク状況の統計関数を定数化
  • addTodoで早期エラーリターン
  • mapを使用する際、keyが漏れている部分があるため追記
  • 編集中を判定する isEditing の削除
  • ES6のオブジェクトプロパティ省略記法

TodoItem

components/TodoItem.jsx
import { EditingTodo } from './EditingTodo';
import { CompletedTodo } from './CompletedTodo';
import { EditButton } from './EditButton';
import { DeleteButton } from './DeleteButton';

export const TodoItem = (props) => {
  const { todo, editProps, todoActions } = props;
  const edit = editProps.currentTodo && editProps.currentTodo.id === todo.id;
  return (
    <>
      {edit ?
        <EditingTodo 
          key={todo.id}
          currentTodo={editProps.currentTodo.text}
          onEditTodo={editProps.onEditTodo}
          cancelEdit={editProps.cancelEdit}
          updateTodos={editProps.updateTodos}
        />
      :
        <li key={todo.id} className={`grid grid-cols-12 items-center gap-x-2 mb-4 ${editProps.currentTodo ? 'opacity-50' : ''}`}>
          <CompletedTodo 
            isCompleted={todo.completed}
            toggleTodo={todoActions.toggleTodo}
            isEditing={editProps.currentTodo}
            todoTitle={todo.text}
          />
          <EditButton
            onClickEdit={todoActions.onClickEdit}
            isEditing={editProps.currentTodo}
            />
          <DeleteButton
            onClickDelete={todoActions.onClickDelete}
            isEditing={editProps.currentTodo}
          />
        </li>
      }
    </>
  );
}

InputTodo

components/InputTodo.jsx
export const InputTodo = (props) => {
    const { todoText, onChangeTodoText, addTodo } = props;
    return (
        <div className='flex mb-8 gap-x-2'>
            <input
                type="text"
                className='flex-1 border-2 border-white rounded-xl p-2'
                value={todoText}
                onChange={onChangeTodoText}
                name='todo-input'
                placeholder="タスクを追加..."
            />
            <button onClick={addTodo} className='bg-blue-500'>追加</button>
        </div>
    );
}

EditingTodo

components/EditingTodo.jsx
export const EditingTodo = (props) => {
  const { currentTodo, onEditTodo, cancelEdit, updateTodos} = props;
  return (
      <li key={currentTodo.id} className='grid grid-cols-12 items-center gap-x-2 mb-4'>
        <input 
            className='col-span-6 border-2 border-white rounded-xl p-2'
            value={currentTodo}
            onChange={onEditTodo}
        />
        <button onClick={cancelEdit} className='col-span-3 bg-zinc-600'>戻る</button>
        <button onClick={updateTodos} className='col-span-3 bg-emerald-600'>更新</button>
      </li>
  );
}

EditButton

components/EditButton.jsx
export const EditButton = (props) => {
  const { onClickEdit, isEditing } = props;
  return (
    <button 
      onClick={onClickEdit} 
      className='col-span-3 bg-zinc-600'
      disabled={isEditing}
    >
      編集
    </button>
  );
}

DeleteButton

components/DeleteButton.jsx
export const DeleteButton = (props) => {
  const { onClickDelete, isEditing } = props;
  return (
    <button 
      onClick={onClickDelete} 
      className='col-span-3 bg-red-600'
      disabled={isEditing}
    >
      削除
    </button>
  );
}

CompletedTodo

components/CompletedTodo.jsx
export const CompletedTodo = (props) => {
    const { isCompleted, toggleTodo, isEditing, todoTitle } = props;
    return (
      <>
        <input 
          type="checkbox"
          checked={isCompleted}
          onChange={toggleTodo}
          className='col-span-1'
          disabled={isEditing}
        />
        <p className={`col-span-5 ${isCompleted ? 'line-through text-gray-500' : ''}`}>
          {todoTitle}
        </p>
      </>
    );
}

App.jsx

App.jsx
import { useRef, useState } from 'react'
import './App.css'
import { InputTodo } from './components/InputTodo';
import { TodoItem } from './components/TodoItem';

function App() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');
  const [currentTodo, setCurrentTodo] = useState(null);
  const nextId = useRef(1);

  const addTodoText = (event) => setInput(event.target.value);

  const addTodo = () => {
    const text = input.trim();
    if (!text) return;
    setTodos([...todos, {
      id: nextId.current++,
      text: input,
      completed: false,
    }])
    setInput('');
  }

  const toggleTodo = (todoId) => {
    setTodos(prevTodos =>
      prevTodos.map(t =>
        t.id === todoId ? { ...t, completed: !t.completed } : t
      )
    );
  }

  const completedTodos = todos.filter(todo => todo.completed).length;
  const incompletedTodos = todos.filter(todo => !todo.completed).length;

  const onEditTodo = (e) => {
    setCurrentTodo({...currentTodo, text: e.target.value});
  }

  const cancelEdit = () => setCurrentTodo(null);

  const updateTodos = () => {
    if (currentTodo && currentTodo.text.trim()) {
      setTodos(todos.map(todo =>
        todo.id === currentTodo.id ? currentTodo : todo
      ));
      setCurrentTodo(null);
    }
  }

  const editTodo = (todo) => setCurrentTodo({...todo});

  const deleteTodo = (todoId) => {
    const message = '本当によろしいですか?';
    if (confirm(message))  {
      setTodos(todos.filter(todo => todo.id !== todoId));
    }
  }

  return (
    <>
      <h1 className='mb-8'>Todo App</h1>
      <InputTodo
        todoText={input}
        onChangeTodoText={addTodoText}
        addTodo={addTodo}
      />
      <div className='mb-8'>
        <p>全てのタスク: {todos.length} / 完了済み: {completedTodos} / 未完了: {incompletedTodos}</p>
      </div>
      <div>
        <ul>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            editProps={{
              currentTodo,
              onEditTodo,
              cancelEdit,
              updateTodos,
            }}
            todoActions={{
              toggleTodo: () => toggleTodo(todo.id),
              onClickEdit : () => editTodo(todo),
              onClickDelete: () => deleteTodo(todo.id),
            }}
          />
        ))}
        </ul>
      </div>
    </>
  )
}

export default App

かなりスッキリました!
コンポーネントすることでコードの管理が楽になったことを実感しました。

感想

初めは App.jsx へ殴り書きをして
動くものができたらコンポーネントに分割するようにしました。

1つのタスクをコンポーネントとして分割する際に props が11個まで膨れ上がった時がありました!ちょっとだけ「Reactって非効率じゃないかな?」って思いましたが、進めていくうちに最終的に3つまで減らすことができました!

編集中を判定する実装では、isEditingが必須だなと感じてすぐにuseStateを定義したのですが、最終的にはいらなくなりました。何でもuseStateすればいいという話では無いですね...

JSX記法では、if文より三項演算子の方がつかい機会が多いなと調べていて感じました。こればかりは現場によって違う可能性もあるので実務に入ってから力をつけようと思います!

参考文献

https://ja.vite.dev/guide/
https://tailwindcss.com/docs/installation/using-vite

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?