見出し画像

【Part2】Dockerで実現!Laravel + React + Viteの「分離構成」開発環境を徹底構築ガイド〜DB作成&実装〜

はじめに

本記事は、前回の記事で構築した Docker 環境をもとに、Todoアプリのバックエンド(Laravel API)とフロントエンド(React)を順に実装していきます。
なお、本記事の前提として、前回の記事「Laravel12 + React + Vite でのTODOアプリ作成(ローカル環境構築)」で説明したDocker環境が構築済みであることを前提としています。
環境構築がまだ完了していない場合は、先に前回の記事で環境を整えてから本記事にお進みください。
📍前章へのページリンク

[使用技術: Docker, Laravel, Vite, React, Nginx,  Mysql, TypeScript]
(最終更新日 2025年05月26日)


💡この記事で行いたいこと

  • Laravel APIを用いたTODOアプリのCRUD機能の実装

  • React + TypeScript + ViteでのTODO一覧表示、作成、編集画面の開発

  • バックエンドAPIとフロントエンドの連携による動的な画面構築



対象読者

本記事は、以下の方々を対象として作成しました。

  • Laravel の基礎的な知識を持っている方

  • Laravel + React での開発を行いたい方

  • バックエンドとフロントエンドを分離した構成で開発したい方



目標機能の概要

Todo管理機能として基本的な CRUD(作成・読み取り・更新・削除)機能を実装します。

1. Todo一覧・作成画面の主な機能

  • Todo の新規作成(タイトル・説明の入力)

  • 作成済み Todo の一覧表示

  • 各 Todo のステータス(未着手/進行中/完了)をワンクリックで更新

  • 各 Todo の削除

  • 「編集」ボタンから、Todo編集画面への遷移

2. Todo編集画面の主な機能

  • タイトル・説明・ステータスの編集

  • 編集内容の保存と、一覧画面への戻り

3. 完成形イメージ

Todo一覧・作成画面:

Todo編集画面:


それでは、前章で構築したDocker環境をベースに、LaravelのAPI実装とReactの画面開発を進めていきます。


1. API - バックエンド開発準備

まず、バックエンド開発の準備としてマイグレーションファイルとモデルを作成し、実際にテーブルを作成します。

テーブル構成

今回のTODO アプリに必要な情報を格納するために、todos テーブルを使用します。
テーブルのカラム構成は以下の通り、シンプルな設計としています。


マイグレーションとモデルの作成

以下のコマンドで、マイグレーションとモデルファイルをまとめて作成します。

docker compose exec backend php artisan make:model Todo -m

マイグレーションファイルはdatabase/migrationsフォルダに作成されます。
マイグレーションファイルの内容をテーブル構成に合わせて、以下のように修正します。

✅backend/database/migrations/2025_05_26_XXXXXX_create_todos_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('todos', function (Blueprint $table) {
            $table->id()->comment('TODO ID');
            $table->string('title', 255)->comment('タイトル');
            $table->text('description')->nullable()->comment('説明');
            $table->integer('status')->default(0)->comment('ステータス');
            $table->timestamps();
            $table->softDeletes()->comment('削除日時');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('todos');
    }
};

モデルファイルはapp/Modelsフォルダに作成されます。
内容をtodosテーブルの構成と合わせ、以下のように修正します。

✅ backend/app/Models/Todo.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Todo extends Model
{
    use SoftDeletes;

    protected $table = 'todos';

    protected $fillable = [
        'title',
        'description',
        'status',
    ];

    protected $casts = [
        'status' => 'integer',
        'created_at'  => 'datetime',
        'updated_at'  => 'datetime',
        'deleted_at'  => 'datetime',
    ];
}

テーブル作成

マイグレーションを実施し、todos テーブルをデータベースに作成します。

docker compose exec backend php artisan migrate

テーブルが追加されたか確認するため、以下コマンドでDBにログインします。パスワードを求められるので “ root “ を入力してMysqlに入ります。

 docker compose exec db mysql -u root -p

使用するデータベースを以下のコマンドで指定します。

USE react_app;

以下のコマンドで存在するテーブル一覧を確認します。

SHOW TABLES;

todosテーブルが作成されていれば成功です。
出力例:

mysql> USE react_app;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> SHOW TABLES;
+-----------------------+
| Tables_in_react_app   |
+-----------------------+
| cache                 |
| cache_locks           |
| failed_jobs           |
| job_batches           |
| jobs                  |
| migrations            |
| password_reset_tokens |
| sessions              |
| todos                 |
| users                 |
+-----------------------+
10 rows in set (0.00 sec)

mysql> 

2. API - バックエンド実装

次に、APIの実装に移ります。ControllerやServiceクラスの作成、バリデーション設定、ルーティングの追加を行い、APIのCRUD機能を完成させます。

Controller の作成

以下のコマンドでコントローラを作成します。

docker compose exec backend php artisan make:controller TodoController

コントローラーへ、以下のTODO情報を操作するための関数を実装します。

  1. index():Todo一覧取得

  2. show():指定されたtodoIdのTodoを1件取得

  3. store():新規Todoを作成

  4. update():指定されたtodoIdのTodoを更新

  5. destroy():指定されたtodoIdのTodoを削除

※今回はバリデーション専用のRequestクラスは作成せず、Controller内で直接実装します。

✅ backend/app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use App\Services\TodoService;

class TodoController extends Controller
{
    protected TodoService $todoService;

    public function __construct(TodoService $todoService)
    {
        $this->todoService = $todoService;
    }

    /**
     * Todo 一覧取得
     *
     * @return JsonResponse
     */
    public function index(): JsonResponse
    {
        $todos = $this->todoService->getList();

        return response()->json(
            $todos,
            Response::HTTP_OK,
            [],
            JSON_UNESCAPED_UNICODE
        );
    }

    /**
     * Todo 取得
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function show(Request $request): JsonResponse
    {
        $request->merge([
            'todoId' => $request->route('todoId'),
        ]);

        $param = $request->validate([
            'todoId' => 'required|integer',
        ]);
        $todoId = $param['todoId'];
        $todo = $this->todoService->getById($todoId);

        return response()->json(
            $todo,
            Response::HTTP_OK,
            [],
            JSON_UNESCAPED_UNICODE
        );
    }

    /**
     * Todo 新規作成
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function store(Request $request): JsonResponse
    {
        $param = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:255',
            'status' => 'nullable|integer',
        ]);
        $todo = $this->todoService->create($param);

        return response()->json(
            $todo,
            Response::HTTP_CREATED,
            [],
            JSON_UNESCAPED_UNICODE
        );
    }

    /**
     * Todo 更新
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function update(Request $request): JsonResponse
    {
        $request->merge([
            'todoId' => $request->route('todoId'),
        ]);

        $param = $request->validate([
            'todoId' => 'required|integer',
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:255',
            'status' => 'required|integer',
        ]);
        $todoId = $param['todoId'];

        $todo = $this->todoService->update($todoId, $param);

        return response()->json(
            $todo,
            Response::HTTP_OK,
            [],
            JSON_UNESCAPED_UNICODE
        );
    }

    /**
     * Todo 削除
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function destroy(Request $request): JsonResponse
    {
        $request->merge([
            'todoId' => $request->route('todoId'),
        ]);

        $param = $request->validate([
            'todoId' => 'required|integer',
        ]);
        $todoId = $param['todoId'];

        $todo = $this->todoService->delete($todoId);

        return response()->json(
            $todo,
            Response::HTTP_OK,
            [],
            JSON_UNESCAPED_UNICODE
        );
    }
}



Service の作成

今回は Laravel のビジネスロジックを Service クラスに分離する構成とし、Controller は API 入出力に専念させます。Repository 層は使わず、Service クラスでデータアクセスも扱います。
Service ディレクトリは、artisanコマンドによる自動生成の対象外のため、以下のファイルを手動で作成する必要があります。
サービスには以下のTODO操作機能を実装します:

  • getList():全TODOリストを取得

  • getById():指定IDのTODOを取得

  • create():新規TODOを作成

  • update():既存TODOを更新

  • delete():TODOを削除

✅ backend/app/Services/TodoService.php

<?php

namespace App\Services;

use App\Models\Todo;

class TodoService
{
    /**
     * TODO 一覧取得
     *
     * @return array
     */
    public function getList(): array
    {
        $todos = Todo::all();

        return [
            'status' => 'success',
            'data'   => $todos,
        ];
    }

    /**
     * TODO 取得
     *
     * @param int $todoId
     * @return array
     */
    public function getById(int $todoId): array
    {
        $todo = Todo::find($todoId);
        if (!$todo) {
            return [
                'status' => 'error',
                'message' => 'Todoリストが見つかりません。',
            ];
        }

        return [
            'status' => 'success',
            'data'   => $todo,
        ];
    }

    /**
     * TODO 新規作成
     *
     * @param array $param
     * @return array
     */
    public function create(array $param): array
    {
        $todos = Todo::create($param);

        return [
            'status' => 'success',
            'data'   => $todos,
        ];
    }

    /**
     * TODO 更新
     *
     * @param int $todoId
     * @param array $param
     * @return array
     */
    public function update(int $todoId, array $param): array
    {
        $todos = Todo::find($todoId);
        if (!$todos) {
            return [
                'status' => 'error',
                'message' => 'Todoリストが見つかりません。',
            ];
        }

        $todos->update($param);

        return [
            'status' => 'success',
            'data'   => $todos,
        ];
    }

    /**
     * TODO 削除
     *
     * @param int $todoId
     * @return array
     */
    public function delete(int $todoId): array
    {
        $todos = Todo::find($todoId);
        if (!$todos) {
            return [
                'status' => 'error',
                'message' => 'Todoリストが見つかりません。',
            ];
        }
        $todos->delete();

        return [
            'status' => 'success',
            'message' => 'Todoリストを削除しました。',
        ];
    }
}

Laravelのバリデーションエラーを日本語化

Laravelのバリデーションエラーメッセージを日本語化対応させていきます。
参考:Laravelのバリデーションエラーを日本語化する
対象のvalidation.php内のattributesにカスタム属性名を追加してください。

✅ backend/resources/lang/ja/validation.php

// カスタム属性名
'attributes' => [
    // Todo
    'title' => 'タイトル',
    'description' => '説明',
    'status' => 'ステータス',
],

ルーティングの追加

Laravel の API ルートは routes/api.php に定義します。
今回は /api/todos 以下に一覧・作成・編集・更新・削除エンドポイントを実装します。

✅ backend/routes/api.php

use App\Http\Controllers\TodoController;

Route::prefix('todos')->group(function () {
    Route::get('/', [TodoController::class, 'index']);
    Route::get('/{todoId}', [TodoController::class, 'show']);
    Route::post('/', [TodoController::class, 'store']);
    Route::put('/{todoId}', [TodoController::class, 'update']);
    Route::delete('/{todoId}', [TodoController::class, 'destroy']);
});



API 動作確認

ルーティングの追加まで完了したら、これでバックエンド側の API が完成です。
試しに一覧取得の API をブラウザで確認してみます。
URL:http://localhost/api/todos
現時点ではデータが登録されていないため、次のような空の配列が返って来るはずです。

{"status":"success","data":[]}

このレスポンスが返ってくれば、API が正しく動作していることが確認できます。


3. React - フロントエンド開発準備

まず、フロントエンド開発の準備として必要なパッケージをインストールし、Reactのルーティング設定や基本的なスタイルを整えます。

必要なパッケージのインストール

まず、React アプリでルーティングと API 通信を行うためのパッケージをインストールします。

  • react-router-dom :React アプリ内でルーティング(画面遷移)を行うためのライブラリ

  • axios:API との HTTP 通信を行うためのクライアントライブラリ

docker compose exec frontend npm install react-router-dom@latest axios@latest

次に、TypeScript を使用する React プロジェクトにおいて、型定義ファイル(TypeScript で補完や型チェックに使用される)を開発用にインストールするために、以下のコマンドを実行します。

cd frontend && npm install --save-dev @types/react @types/react-dom


補足:ホスト環境で型定義を入れる理由
現状のDocker環境では、 node_modules をコンテナ専用にマウントする構成にしているため、ホスト(VSCode)からはそれを直接参照できません。 node_modules をコンテナ専用にマウントすることで、ホストとコンテナでアーキテクチャ(例: arm64 vs amd64)が異なる場合に、依存の衝突を避ける効果があります。
そのため、エディタ補完や型チェックが効かなくなるため、@types 系パッケージはホスト側にも別途インストールする必要があります。



デザインの事前準備(初期状態のスタイルをリセット)

画面のスタイルを App.css だけで制御できるようにするため、以下の対応を行います。

  • main.tsx:読み込まれているデフォルトの CSS(index.css)をコメントアウト

  • App.css:一度すべてのスタイルを削除し、白紙の状態にします

✅ frontend/src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// import './index.css' // ← スタイルの干渉を防ぐためコメントアウト
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

✅ frontend/src/App.css

/* 一度すべて削除して空にする */



React の環境設定、ルーティング、画面コンポーネントの準備

ディレクトリ構成

まず、以下のように ページコンポーネント用のディレクトリを作成します。
  

frontend/
├─ src/
│  ├─ pages/
│  │  ├─ TodoList.tsx     #Todo一覧・作成
│  │  └─ TodoEdit.tsx     #Todo編集
│  ├─ App.tsx             #ルーティング定義
│  ├─ App.css             #共通スタイル
│  ├─ main.tsx            
│  ├─ index.css 
├─ .env     



.envファイルの設定

frontendプロジェクト配下に.envファイルを作成し、APIのURLを設定します。
これにより、コード内でenvファイルからAPIのベースURLを参照できるようになります。
✅ frontend/.env

VITE_API_BASE_URL=http://localhost/api



ルーティングの設定

次にApp.tsx にて、React Router を用いて画面遷移を設定します。
機能の流れ

  1. /にアクセスするとTodoリストの作成と一覧の確認を行えるようにする。

  2. /edit/:idにアクセスすると、指定したIDのTodo編集画面が表示されるようにする。

✅ frontend/src/App.tsx

  • BrowserRouter: URL に基づいたルーティングを設定

  • Routes / Route: 各パスに対するコンポーネントの割り当て

  • TodoList / TodoEdit: 一覧・編集ページの画面コンポーネント

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import TodoList from './pages/TodoList';
import TodoEdit from './pages/TodoEdit';

function App() {
  return (
    <Router>
      <Routes>
        {/* Todoリストのページ */}
        <Route path="/" element={<TodoList />} />
        {/* Todo編集のページ */}
        <Route path="/edit/:id" element={<TodoEdit />} />
      </Routes>
    </Router>
  );
}

export default App;
     



コンポーネントページ作成

メインとなるTODOアプリの新規作成・一覧表示と編集画面のコンポーネントファイルを作成します。
✅ src/pages/TodoList.tsx

export default function TodoList() {
  return (
    <div>
      <h1>TODO新規作成・一覧</h1>
      <p>ここにTODO作成画面を表示します。</p>
      <p>ここにTODOリストを表示します。</p>
    </div>
  );
}


http://localhost:5173 にアクセスし、新規作成・一覧ページが表示されることを確認します。
✅ src/pages/TodoEdit.tsx
編集画面の作成

export default function TodoEdit() {
  return (
    <div>
      <h1>TODO編集</h1>
      <p>ここに編集画面を表示します。</p>
    </div>
  );
}


http://localhost:5173/edit/1 にアクセスし、編集ページが表示されることを確認します。


4. React - フロントエンド実装

次に、具体的な画面の作成に進みます。Todo一覧画面や新規作成、編集画面のコンポーネントを実装し、スタイルを適用して操作できるUIを構築します。

新規作成+一覧表示(TodoList.tsx)

  • 初回レンダリング時に一覧データを取得(useEffect)
    コンポーネントが最初に画面表示される際に、axios.get() で Laravel API の /api/todos にリクエストを送り、DBにあるTodo一覧を取得しています。取得結果はtodosセットし、画面に反映します。

  • 新規Todo作成(addTodo関数)
    入力フォームのタイトル・説明の値をnewTodoで管理し、作成ボタンを押すとPOSTでAPIに送信します。成功したらフォームを空にして一覧を再取得。バリデーションチェックも行い、エラーとして画面へ表示対応も行います。

  • バリデーションチェック(validateTodo関数)
    タイトルが空でないか・255文字以内か、説明が255文字以内かをローカルでもチェックし、問題があればアラート表示して新規作成をブロックします。

  • ステータス更新(updateStatus関数)
    該当Todoを検索し、ステータスをPUTでAPIに送信。(その際、タイトル・説明も合わせて送信)更新が成功すると一覧再取得して画面を最新化します。

  • 削除(deleteTodo関数)
    削除ボタン押下で確認ダイアログを表示し、OKならDELETEリクエストで該当Todoを論理削除。完了後に一覧を再取得して画面を更新します。

  • 編集ページへのリンク(Link)
    各Todoの編集ボタンはreact-router-domのLinkを使い、/edit/:id に遷移させます。

✅ frontend/src/pages/TodoList.tsx

import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';
import '../App.css';

// API URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

// インターフェース
interface Todo {
  id: number;
  title: string;
  description: string | null;
  status: number;
  created_at: string;
  updated_at: string;
}

// コンポーネント
export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTodo, setNewTodo] = useState<{ title: string; description: string }>({ title: '', description: '' });
  const [errors, setErrors] = useState<{ [key: string]: string[] }>({});

  // Todo 一覧取得
  const getTodos = useCallback(() => {
    axios.get(`${API_BASE_URL}/todos`)
      .then(res => {
        const todos = Array.isArray(res.data.data) ? res.data.data : [];
        setTodos(todos);
      })
      .catch(console.error);
  }, [API_BASE_URL]);

  // バリデーションチェック
  const validateTodo = (todo: { title: string; description?: string | null }) => {
    if (!todo.title) return 'タイトルを入力してください';
    if (todo.title.length > 255) return 'タイトルは255文字以内で入力してください';
    if (todo.description && todo.description.length > 255) return '説明は255文字以内で入力してください';
    return null;
  };

  // Todo 作成
  const addTodo = () => {
    // バリデーションチェック
    const error = validateTodo(newTodo);
    if (error) {
      alert(error);
      return;
    }

    axios.post(`${API_BASE_URL}/todos`, {
      ...newTodo,
      status: 0,
    })
      .then(res => {
        if (res.status === 201) {
          setNewTodo({ title: '', description: '' });
          setErrors({});
          getTodos();
        }
      })
      .catch(error => {
        if (error.response?.status === 422) {
          setErrors(error.response.data.errors);
        } else {
          console.error(error);
        }
      });
  };

  // Todo ステータス更新
  const updateStatus = (id: number, status: number) => {
    const todo = todos.find(t => t.id === id);
    if (!todo) return;

    axios.put(`${API_BASE_URL}/todos/${id}`, {
      title: todo.title,
      description: todo.description,
      status,
    })
      .then(getTodos)
      .catch(console.error);
  };

  // Todo 削除
  const deleteTodo = (id: number) => {
    if (window.confirm('本当に削除しますか?')) {
      axios.delete(`${API_BASE_URL}/todos/${id}`)
        .then(getTodos)
        .catch(console.error);
    }
  };

  useEffect(() => {
    getTodos();
  }, [getTodos]);

  return (
    <div className="page-container">
      <h1 className="section-title">➕ Todo 作成</h1>

      <div className="form-section">
        <div className="form-group">
          <label>タイトル:</label>
          <input
            type="text"
            className="input-text"
            value={newTodo.title}
            onChange={e => setNewTodo({ ...newTodo, title: e.target.value })}
          />
          {errors.title && <div className="error-text">{errors.title.join(', ')}</div>}
        </div>

        <div className="form-group">
          <label>説明:</label>
          <textarea
            className="textarea-text"
            value={newTodo.description}
            onChange={e => setNewTodo({ ...newTodo, description: e.target.value })}
          />
          {errors.description && <div className="error-text">{errors.description.join(', ')}</div>}
        </div>

        <div className="form-group">
          <button className="btn-primary" onClick={addTodo}>➕ Todo 作成</button>
        </div>
      </div>

      <hr />

      <h1 className="section-title">📋 Todo 一覧</h1>

      {!todos.length && <p>Todoがありません。</p>}

      <ul className="todo-list">
        {todos.map(todo => (
          <li key={todo.id} className="todo-card">
            <div className="todo-content">
              <p className="todo-title"><strong>{todo.title}</strong></p>
              <p className="todo-description">{todo.description}</p>

              <div className="date-info">
                <span>作成日: {new Date(todo.created_at).toLocaleString()}</span>
                <span>更新日: {new Date(todo.updated_at).toLocaleString()}</span>
              </div>

              <div className="status-buttons">
                {[0, 1, 2].map(s => {
                  const isActive = todo.status == s;
                  return (
                    <button
                      key={s}
                      onClick={() => updateStatus(todo.id, s)}
                      className={`status-button ${
                        isActive
                          ? ['status-active-not-started', 'status-active-in-progress', 'status-active-done'][s]
                          : 'status-inactive'
                      }`}
                    >
                      {['未着手', '進行中', '完了'][s]}
                    </button>
                  );
                })}
              </div>
            </div>

            <div className="todo-actions">
              <Link to={`/edit/${todo.id}`}>
                <button className="btn-secondary">✏️ 編集</button>
              </Link>
              <button className="btn-secondary" onClick={() => deleteTodo(todo.id)}>🗑️ 削除</button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}



編集画面(TodoEdit.tsx)

  • URLパラメータからTodo IDを取得(useParams)
    useParamsを使用して、URL上の :id パラメータを取得します。

  • 初回レンダリング時に対象データを取得(getTodo 関数)
    コンポーネントが表示されたときに useEffect 内で axios.get('/api/todos/:id') を実行し、対象のTodoデータをAPIから取得します。
    成功すると、レスポンスを todo にセットし、画面上の各入力欄にデータが反映されます。

  • Todo更新(updateTodo関数)
    保存ボタンを押すと PUT /api/todos/:id を実行してバックエンドへ更新リクエストを送ります。
    リクエスト成功時はトップページ(/)に自動遷移。失敗時はバリデーションエラーをキャッチして errors にセットし、画面に表示します。

  • バリデーションチェック(validateTodo関数)
    一覧画面と同様に更新時にエラーがあればブロックし、エラーメッセージをアラートにて表示します。

  • ステータス変更
    ステータス(未着手・進行中・完了)をボタンで切り替え可能とし、
    ボタンの見た目は状態に応じて動的に切り替わり、現在のステータスが視覚的にわかるようにしています。

  • 戻るボタンで一覧画面へ遷移
    戻るボタンでトップ画面へ戻れるようにし、編集のキャンセルをおこなう動線を用意しています。

✅ frontend/src/pages/TodoEdit.tsx

import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { useParams, useNavigate } from 'react-router-dom';
import '../App.css';

// API URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

// インターフェース
interface Todo {
  title: string;
  description: string | null;
  status: number;
  created_at: string;
  updated_at: string;
}

// コンポーネント
export default function TodoEdit() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const [todo, setTodo] = useState<Todo | null>(null);
  const [errors, setErrors] = useState<{ [key: string]: string[] }>({});

  // Todo 取得
  const getTodo = useCallback(() => {
    axios.get(`${API_BASE_URL}/todos/${id}`)
      .then(res => {
        const data = res.data.data;
        setTodo({
          title: data.title,
          description: data.description,
          status: data.status,
          created_at: data.created_at,
          updated_at: data.updated_at,
        });
      })
      .catch(console.error);
  }, [API_BASE_URL, id]);

  // バリデーションチェック
  const validateTodo = (todo: { title: string; description?: string | null }) => {
    if (!todo.title) return 'タイトルを入力してください';
    if (todo.title.length > 255) return 'タイトルは255文字以内で入力してください';
    if (todo.description && todo.description.length > 255) return '説明は255文字以内で入力してください';
    return null;
  };

  // Todo 更新
  const updateTodo = () => {
    if (!todo) return;

    // バリデーションチェック
    const error = validateTodo(todo);
    if (error) {
      alert(error);
      return;
    }

    axios.put(`${API_BASE_URL}/todos/${id}`, todo)
      .then(() => navigate('/'))
      .catch((error) => {
        if (error.response?.status === 422) {
          setErrors(error.response.data.errors);
        } else {
          console.error(error);
        }
      });
  };

  useEffect(() => {
    getTodo();
  }, [getTodo]);

  // 読み込み中表示
  if (!todo) {
    return <div className="page-container">読み込み中...</div>;
  }

  return (
    <div className="page-container">
      <h1 className="section-title">✏️ Todo 編集</h1>

      <section className="form-section todo-form">
        <div className="form-group">
          <label>タイトル:</label>
          <input
            type="text"
            value={todo.title}
            onChange={e => setTodo({ ...todo, title: e.target.value })}
            className="input-text"
          />
          {errors.title && <div className="error-text">{errors.title.join(', ')}</div>}
        </div>

        <div className="form-group">
          <label>説明:</label>
          <textarea
            value={todo.description || ''}
            onChange={e => setTodo({ ...todo, description: e.target.value })}
            className="textarea-text"
          />
          {errors.description && <div className="error-text">{errors.description.join(', ')}</div>}
        </div>

        <div className="status-buttons">
          {[0, 1, 2].map(s => {
            const isActive = todo.status === s;
            return (
              <button
                key={s}
                onClick={() => setTodo({ ...todo, status: s })}
                className={`status-button ${
                  isActive
                    ? ['status-active-not-started', 'status-active-in-progress', 'status-active-done'][s]
                    : 'status-inactive'
                }`}
              >
                {['未着手', '進行中', '完了'][s]}
              </button>
            );
          })}
        </div>

        <div className="form-actions">
          <button className="btn-primary" onClick={updateTodo}>💾 保存</button>
          <button className="btn-secondary" onClick={() => navigate('/')}>戻る</button>
        </div>
      </section>
    </div>
  );
}



スタイルの適用(App.css)

最後に、App.cssを使用してTodoList.tsxとTodoEdit.tsxのデザインを適用していきます。

✅ frontend/src/App.css

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  background-color: #121212;
  color: #ddd;
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* 全体コンテナ */
.page-container {
  max-width: 900px;
  margin: 40px auto;
  padding: 0 20px;
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}

/* セクションタイトル */
.section-title {
  font-size: 1.8rem;
  margin-bottom: 20px;
  color: #eee;
  border-bottom: 2px solid #4a90e2;
  padding-bottom: 6px;
}

/* フォーム */
.form-section {
  background-color: #1e1e1e;
  border: 1px solid #444;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 30px;
  box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3);
}

.form-group {
  margin-bottom: 15px;
  display: flex;
  flex-direction: column;
}

.form-group label {
  font-weight: 600;
  margin-bottom: 6px;
  color: #ccc;
}

.form-actions {
  margin-top: 24px;
  display: flex;
  gap: 12px;
  justify-content: flex-end;
}

/* 入力欄 */
.input-text,
.textarea-text {
  background-color: #222;
  border: 1px solid #555;
  border-radius: 5px;
  padding: 8px 12px;
  font-size: 1rem;
  font-family: inherit;
  color: #eee;
  transition: border-color 0.3s ease;
}

.input-text:focus,
.textarea-text:focus {
  border-color: #4a90e2;
  outline: none;
  box-shadow: 0 0 5px #4a90e2aa;
  background-color: #2a2a2a;
}

.textarea-text {
  min-height: 200px;
}

/* エラーテキスト */
.error-text {
  margin-top: 4px;
  color: #ff6b6b;
  font-size: 0.875rem;
}

/* ボタン */
.btn-primary {
  background-color: #4a90e2;
  color: white;
  border: none;
  padding: 10px 18px;
  font-size: 1rem;
  border-radius: 6px;
  cursor: pointer;
  width: fit-content;
  transition: background-color 0.25s ease;
}

.btn-primary:hover {
  background-color: #357ab8;
}

/* Todoリスト */
.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

/* Todoカード */
.todo-card {
  display: flex;
  justify-content: space-between;
  background: #1e1e1e;
  border: 1px solid #444;
  border-radius: 8px;
  padding: 18px 24px;
  margin-bottom: 18px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
  transition: box-shadow 0.3s ease;
}

.todo-card:hover {
  box-shadow: 0 4px 10px rgba(74, 144, 226, 0.7);
}

/* 左側コンテンツ */
.todo-content {
  flex-grow: 1;
  max-width: 75%;
}

/* タイトル */
.todo-title {
  font-size: 1.3rem;
  margin-bottom: 8px;
  color: #eee;
}

/* 説明 */
.todo-description {
  white-space: pre-wrap;
  margin-bottom: 12px;
  color: #ffffff;
  font-size: 1rem;
  line-height: 1.4;
}

/* 日付情報 */
.date-info {
  font-size: 0.8rem;
  color: #888;
  display: flex;
  gap: 20px;
  margin-bottom: 14px;
}

/* ステータスボタン */
.status-buttons {
  display: flex;
  gap: 12px;
}

/* ステータス各ボタン */
.status-button {
  flex: 1;
  padding: 8px 12px;
  border: 2px solid transparent;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background-color 0.3s ease, border-color 0.3s ease;
  color: #ddd;
}

/* 状態ボタン */
.status-active-not-started {
  background-color: #0d47a1;
  border-color: #2196f3;
  color: #e3f2fd;
}

.status-active-in-progress {
  background-color: #e65100;
  border-color: #fb8c00;
  color: #fff3e0;
}

.status-active-done {
  background-color: #1b5e20;
  border-color: #4caf50;
  color: #e8f5e9;
}

/* 非アクティブ */
.status-inactive {
  background-color: #333;
  color: #888;
  border-color: #555;
}

.status-button:hover {
  filter: brightness(1.1);
}

/* 右側コンテンツ */
.todo-actions {
  display: flex;
  flex-direction: column;
  gap: 10px;
  justify-content: center;
}

/* 編集・削除ボタン */
.btn-secondary {
  min-width: 100px;
  width: auto;
  background-color: #333;
  border: 1px solid #555;
  color: #ccc;
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color 0.3s ease;
  width: 100px;
  text-align: center;
}

.btn-secondary:hover {
  background-color: #555;
  border-color: #777;
  color: #fff;
}


🛠動作確認

ここまでで、Todoアプリの基本機能が実装できました。
実際にブラウザで http://localhost:5173 を開き、作成・一覧表示・編集への遷移などが問題なく動作するかどうかを確認してください。
もし動作に問題があれば、Dockerのコンテナや、APIエンドポイントのURL設定などの見直しをお願いいたします。

🙇‍♂️最後に

今回は、Docker上にLaravel APIとReact(Vite)+ TypeScriptの開発環境を構築し、Todoアプリの基本的な作成・一覧表示・編集機能を実装しました。
Dockerfileとdocker-compose.ymlについては、本来は開発用と本番用で設定を分けるべきですが、本記事では開発に焦点を当てた構成としています。
今後は、削除した項目の復元機能や検索・フィルタリング機能、さらに認証・認可機能などを実装することで、より実用的なアプリケーションへと発展させることができます。
引き続き学習を重ね、さらなるスキルアップを目指していきます。
本記事が、フルスタック開発の参考となれば幸いです。最後までお読みいただき、ありがとうございました。


いいなと思ったら応援しよう!

この記事が参加している募集