0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📅React + Laravelで簡単なTODOアプリを作成📅

Posted at

これまでReactとLaravelを学んできたので合わせたTODOアプリを作成。*記録用

ReactとLaravelを連携させた簡単なアプリを作成しようとしていたが下記のような問題にぶち当たり簡単なアプリでも少し手間がかかってしまうことが分かった。
・API設計・実装が必須
・ページ遷移やデータ連携も自前で構築
・Laravelの便利な機能をReact側で直接使えなくなる

上記を解決するためにInertia.js(イナーシャ)という
サーバーサイドのルーティングとコントローラーを活用しながら、Reactのコンポーネントで構築されたシングルページアプリケーション(SPA)を開発できる、バックエンドフレームワークフロントエンドフレームワークを「橋渡し」するためのライブラリを使用した

Inertia.jsの具体的なメリット

1.シンプルさと生産性の向上:

・API設計、エンドポイントのドキュメント作成、クロスオリジン設定(CORS)などが一切不要になります。

・フォームのバリデーションや認証など、サーバーサイドの機能(LaravelのAuthやValidationなど)をそのまま利用できます。フロントエンドとバックエンドでロジックを重複して書く必要がありません。

2.フルスタック開発者のための効率的なアプローチ:

・PHP(Laravel)やRuby(Rails)といったバックエンドの知識を最大限に活かしつつ、React/Vueのモダンなコンポーネントベースの開発体験を得られます。

3.SPAの利点(高速なページ遷移)を享受:

・一度読み込んだJavaScriptやCSSはそのまま再利用されるため、ページ遷移が非常に高速で、ユーザーエクスペリエンスが向上します。'

Inertia.jsのデメリット・注意点

1「モノリシック」な構造:

・フロントエンドとバックエンドが密接に連携するため、完全に分離されたマイクロサービスや、複数のフロントエンドが同じAPIを利用するような大規模なシステムには向いていません。

2学習コスト:

・従来のモノリシックな開発とも、従来のSPA開発とも異なる「Inertiaの流儀」を理解する必要があります。特に、データの受け渡し方やフォームの扱い方には慣れが必要です。

3SEO:

・Inertia.js自体には、サーバーサイドレンダリング(SSR)の機能はありません。そのため、SEOを重視する場合は、SSRに対応した別のソリューション(例:Nuxt.js, Next.js)を検討する必要があります。ただし、動的にメタタグを更新することは可能です。

まとめると
Inertia.jsは、バックエンドとフロントエンドが密接に連携する、比較的小〜中規模のアプリケーション開発に適している

なので、頭の整理もかねてInertia.jsを使用して簡単なTODOアプリを作成する!

実際に作成する画面

スクリーンショット 2025-08-27 161819.png

環境設定

1.Laravelプロジェクトの作成

composer create-project laravel/laravel 「プロジェクト名」

2.認証のための機能Laravel Breezeのインストール

// 1で作成したプロジェクトにcdで入ってから実行
composer require laravel/breeze --dev

3.Reactのインストール

// 1で作成したプロジェクトにcdで入ってから実行
php artisan breeze:install react

※php artisan breeze:install react は「React+Breeze(認証)」のセットアップコマンドですがInertia.jsも自動でインストールされます。

もしも「composer.json, package.json」Inertia.jsが入っていなかったら
//Laravel側のインストール
composer require inertiajs/inertia-laravel

//React側のインストール
npm install @inertiajs/react

両方必要になるのでインストールしておく

4..envのDBを変更する
今回MySQLを使用して、XAMPPで接続する。

DB_CONNECTION=mysql
//DB_HOSTは使用したいホスト名を記載(127.0.0.1の場合あり)
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=Inertia
DB_USERNAME=root
//DB_PASSWORDは決まっていれば入力
DB_PASSWORD=root

※XAMPPのインストールなどはしていると仮定して進めていく。

テーブル作成、ダミーデータ作成

1.app.phpと.envを変更

app.php
//4つを変更
'timezone' => 'Asia/Tokyo'
'locale' => env('APP_LOCALE', 'ja'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'ja'),
'faker_locale' => env('APP_FAKER_LOCALE', 'ja_JP'),
.env
APP_LOCALE=ja
APP_FALLBACK_LOCALE=ja
APP_FAKER_LOCALE=ja_JP

これをしていないとダミーデータが日本語にならない。

2.Todosモデル、コントローラー、マイグレーションファイル、ファクトリーを作成する。

php artisan make:model Todos -mcrf

-mcrfとは
-m : マイグレーションファイル(テーブル定義)を同時に作成
-c : コントローラーファイルを同時に作成
-r : リソースコントローラー(RESTful構造)で作成
-f : ファクトリーファイル(ダミーデータ生成用)を同時に作成

3. 作成したマイグレーションファイルに追加する。

//create_todos_table.php
Schema::create('todos', function (Blueprint $table) {
     $table->id();
+    $table->string('title', 30);
+    $table->string('content');
+    $table->string('category', 10);
    $table->timestamps();
});

4. 作成したモデルに追加する

//Todos.php
class Todos extends Model
{
    use HasFactory;
+    protected $fillable = [
+        'title',
+        'content',
+        'category',
+    ];
}
protected $fillableとは

Eloquentモデルで「ホワイトリスト方式」で一括代入(Mass Assignment)を安全に行うためです。

これを設定することで、create や update などで配列から値を一括でモデルに代入する際、指定したカラム(ここでは 'title', 'content', 'category')だけが代入可能になります。
これにより、意図しないカラムへの値の代入(セキュリティ上の問題)を防ぐことができます。

5. 作成したファクトリーに追加する

//TodosFactory.php
class TodosFactory extends Factory
{
    public function definition(): array
    {
+        $categories = ['title', 'content', 'category'];
        return [
+            'title' => $this->faker->realText(12),
+            'content' => $this->faker->realText(20),
+            'category' => $this->faker->randomElement($categories),
        ];
    }
}

Factory(ファクトリー)
Laravelでテストや開発用に「ダミーデータ(テストデータ)」を自動生成する仕組み。後にSeederで使用して作成する。

6. Seederファイルに追加する

//DatabaseSeeder.php
- use App\Models\User;
use Illuminate\Database\Seeder;
+ use App\Models\Todos;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
+        Todos::factory()->count(10)->create();

-         User::factory()->create([
-            'name' => 'Test User',
-            'email' => 'test@example.com',
        ]);
    }
}

factory()で作成したデータを10件作成するコードを記載。

7. Seederを実行して、テストデータを作成する。

php artisan migrate --seed

スクリーンショット 2025-08-27 180052.png
※factoryでrealTextにしないと日本語にならない可能性はあり。

コントローラーファイルの追加(Inertiaの接続)

TodosController.php
<?php

namespace App\Http\Controllers;

use App\Models\Todos;
use Illuminate\Http\Request;
use Inertia\Inertia; // 追加

class TodosController extends Controller
{
    public function index()
    {
        //すべてのTodoを取得
        $todos = Todos::all();
        // Inertiaを使ってビューにデータを渡す
        return Inertia::render('Todos/Index',['todos'=>$todos,'message' => session('message')]);
    }

    public function store(Request $request)
    {
        //バリデーション
        $request->validate([
            'title' => 'required|max:12',
            'content' => 'required|max:20',
            'category' => 'required|max:10',
        ]);

        //登録処理
        $todos = new Todos($request->input());
        $todos->save();
        return redirect('todos')->with('message','登録しました');
    }

    public function update(Request $request, $id)
    {
        //バリデーション
        $request->validate([
            'title' => 'required|max:12',
            'content' => 'required|max:20',
            'category' => 'required|max:10',
        ]);

        //更新処理
        $todos = Todos::find($id);
        $todos->fill($request->input())->saveOrFail();
        return redirect('todos')->with('message','更新しました');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($id)
    {
        //削除処理
        $todos = Todos::find($id);
        $todos->delete();
        return redirect('todos')->with('message','削除しました');
    }
}
補足
//index()
return Inertia::render('Todos/Index',['books'=>$todos,'message' => session('message')]);

Inertia.jsを使って「Todos/Index」ページ(フロント側のVueやReactコンポーネント)にデータを渡して表示するもの

//update()
$todos->fill($request->input())->saveOrFail();

・$todos->fill($request->input())
→ リクエストの値(title, content, categoryなど)をモデルに一括でセットする。

・saveOrFail()
→ 保存処理を実行し、失敗した場合は例外(エラー)を投げる。

つまり「送信されたデータでTodoを更新し、失敗したらエラーにする」処理

ルーティングファイルの追加

//web.php
use App\Http\Controllers\ProfileController;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Http\Controllers\TodosController; // 追加

Route::get('/', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
    
    // Todoのルーティング
+    Route::get('/todos', [TodosController::class, 'index'])->name('todos.index');
+    Route::post('/todos/store', [TodosController::class, 'store'])->name('todos.store');
+    Route::put('/todos/update/{id}', [TodosController::class, 'update'])->name('todos.update');
+    Route::delete('/todos/destroy/{id}', [TodosController::class, 'destroy'])->name('todos.destroy');
});

require __DIR__.'/auth.php';

ログイン後の画面のメニューにTodosを追加する

1. ログイン画面を入るために開発のサーバーを起動する

//Laravelの開発サーバー起動
php artisan serve

//Reactの開発サーバー起動
npm run dev

Laravelだけの開発サーバーだけでログインの個所は問題ないが、ログイン後はReactになるので一緒に立ち上げておく。

2. ログイン画面で登録してログインする
スクリーンショット 2025-08-28 100556.png

2-1 Registerをクリックしてユーザー登録
スクリーンショット 2025-08-28 101237.png

2-2 ログイン画面に入っているか確認、DBにUserが登録されているかも確認
スクリーンショット 2025-08-28 101435.png

スクリーンショット 2025-08-28 101416.png

3. ログインメニューにTodosを追加する
resources/js/Layouts/AuthenticatedLayout.jsxのファイルに追加していく。(メニュー画面のコンポーネント)

//AuthenticatedLayout.jsx 26行目~
<div className="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
    <NavLink
        href={route('dashboard')}
        active={route().current('dashboard')}
    >
        Dashboard
    </NavLink>
</div>

<div className="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
    <NavLink
        href={route('todos.index')}
        active={route().current('todos.index')}
    >
        Todos
    </NavLink>
</div>                    

routeの個所についてはWeb.phpに設定して、コントローラーに設定されているInertia::render('Todos/index')に遷移している。

Inertia::render('Todos/index')はReactのresources/js/Pages/Todos/index.jsxに遷移されている

スクリーンショット 2025-08-28 104122.png

Todosページを作成していく。

1. resources/js/Pages/Todos/index.jsx
このディレクトリ配置にindex.jsxを追加する。

2. ダッシュボードと同じ画面にするためresources/js/Pages/Dashboard.jsxのコードはコピーして貼り付ける

index.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';

export default function Dashboard() {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800">
                    Dashboard
                </h2>
            }
        >
            <Head title="Dashboard" />

            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                        <div className="p-6 text-gray-900">
                            Todos Page
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

スクリーンショット 2025-08-28 105412.png

3. コントローラーで設定しているDBの情報などをindex.jsxで使用するために引数で渡す

//index.jsx
+ export default function Dashboard({auth, todos, message) {

引数に設定しているのはTodosController.phpのindexの個所

TodosController.php
return Inertia::render('Todos/index',['todos'=>$todos,'message' => session('message')]);
//:render('Todos/index')= auth
//'todos'=>$todos= todos
//'message' => session('message')= message

4. DBのtodosテーブルに記載されている情報を

で表示する
//index.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';

export default function Dashboard({auth, todos, message}) {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800">
                    Dashboard
                </h2>
            }
        >
            <Head title="Dashboard" />

            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                        <div className="p-6 text-gray-900">
                            Todos Page
                        </div>

+                        <div>
+                            <table className='w-full bg-gray-100 mt-2'>
+                                <thead className='bg-blue-200'>
+                                    <tr className='text-green-600'>
+                                        <th className='border border-gray-400 p-2'>ID</th>
+                                        <th className='border border-gray-400 p-2'>Title</th>
+                                        <th className='border border-gray-400 p-2'>Content</th>
+                                        <th className='border border-gray-400 p-2'>Category</th>
+                                        <th className='border border-gray-400 p-2'></th>
+                                        <th className='border border-gray-400 p-2'></th>
+                                    </tr>
+                                </thead>
+                                <tbody className='bg-white'>
+                                    {todos.map((todos) => (
+                                        <tr key={todos.id}>
+                                            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
+                                            <td className='border border-gray-400 p-2'>{todos.title}</td>
+                                            <td className='border border-gray-400 p-2'>{todos.content}</td>
+                                            <td className='border border-gray-400 p-2'>{todos.category}</td>
+                                            <td className='border border-gray-400 p-2 text-center'>
+                                            </td>
+                                            <td className='border border-gray-400 p-2 text-center'>
+                                            </td>
+                                        </tr>
+                                    ))}
+                                </tbody>
+                            </table>
+                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

スクリーンショット 2025-08-28 112014.png

登録ボタンを作成する

1. resources/js/Pages/Profile/Partials/DeleteUserForm.jsxが削除ボタンなので、コピーしてresources/js/Pages/Profile/Partials/RegisterButton.jsxに登録ボタンを作成する。

RegisterButton.jsx
//DeleteUserForm.jsxコピーして作成したもの
import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react';
import { useRef, useState } from 'react';

export default function RegisterButton({ className = '' }) {
    const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
    const passwordInput = useRef();

    const {
        data,
        setData,
        delete: destroy,
        processing,
        reset,
        errors,
        clearErrors,
    } = useForm({
        password: '',
    });

    const confirmUserDeletion = () => {
        setConfirmingUserDeletion(true);
    };

    const deleteUser = (e) => {
        e.preventDefault();

        destroy(route('profile.destroy'), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

    const closeModal = () => {
        setConfirmingUserDeletion(false);

        clearErrors();
        reset();
    };

    return (
        <section className={`space-y-6 ${className}`}>
            <header>
                <h2 className="text-lg font-medium text-gray-900">
                    Delete Account
                </h2>

                <p className="mt-1 text-sm text-gray-600">
                    Once your account is deleted, all of its resources and data
                    will be permanently deleted. Before deleting your account,
                    please download any data or information that you wish to
                    retain.
                </p>
            </header>

            <DangerButton onClick={confirmUserDeletion}>
                Delete Account
            </DangerButton>

            <Modal show={confirmingUserDeletion} onClose={closeModal}>
                <form onSubmit={deleteUser} className="p-6">
                    <h2 className="text-lg font-medium text-gray-900">
                        Are you sure you want to delete your account?
                    </h2>

                    <p className="mt-1 text-sm text-gray-600">
                        Once your account is deleted, all of its resources and
                        data will be permanently deleted. Please enter your
                        password to confirm you would like to permanently delete
                        your account.
                    </p>

                    <div className="mt-6">
                        <InputLabel
                            htmlFor="password"
                            value="Password"
                            className="sr-only"
                        />

                        <TextInput
                            id="password"
                            type="password"
                            name="password"
                            ref={passwordInput}
                            value={data.password}
                            onChange={(e) =>
                                setData('password', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="Password"
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>

                    <div className="mt-6 flex justify-end">
                        <SecondaryButton onClick={closeModal}>
                            Cancel
                        </SecondaryButton>

                        <DangerButton className="ms-3" disabled={processing}>
                            Delete Account
                        </DangerButton>
                    </div>
                </form>
            </Modal>
        </section>
    );
}

2. RegisterButton.jsxのヘッダー部分を削除

//RegisterButton.jsx
- <header>
-    <h2 className="text-lg font-medium text-gray-900">
-        Delete Account
-    </h2>
-    
-    <p className="mt-1 text-sm text-gray-600">
-        Once your account is deleted, all of its resources and data
-        will be permanently deleted. Before deleting your account,
-        please download any data or information that you wish to
-        retain.
-    </p>
-</header>

3. index.jsxにインポートして表示させる。
(RegisterButtoを囲っているdivはレイアウトのため削除)

//index.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
+ import RegisterButton from '../Profile/Partials/RegisterButton';

export default function Dashboard({auth, todos, message}) {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800">
                    Todo Page
                </h2>
            }
        >
            <Head title="Dashboard" />

            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg">
-                        <div className="p-6 text-gray-900">
+                            <RegisterButton />
-                        </div>

                        <div>
                            <table className='w-full bg-gray-100 mt-2'>
                                <thead className='bg-blue-200'>
                                    <tr className='text-green-600'>
                                        <th className='border border-gray-400 p-2'>ID</th>
                                        <th className='border border-gray-400 p-2'>Title</th>
                                        <th className='border border-gray-400 p-2'>Content</th>
                                        <th className='border border-gray-400 p-2'>Category</th>
                                        <th className='border border-gray-400 p-2'></th>
                                        <th className='border border-gray-400 p-2'></th>
                                    </tr>
                                </thead>
                                <tbody className='bg-white'>
                                    {todos.map((todos) => (
                                        <tr key={todos.id}>
                                            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
                                            <td className='border border-gray-400 p-2'>{todos.title}</td>
                                            <td className='border border-gray-400 p-2'>{todos.content}</td>
                                            <td className='border border-gray-400 p-2'>{todos.category}</td>
                                            <td className='border border-gray-400 p-2 text-center'>
                                            </td>
                                            <td className='border border-gray-400 p-2 text-center'>
                                            </td>
                                        </tr>
                                    ))}
                                </tbody>
                            </table>
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

4. ボタンの色と登録ボタンに変更

//RegisterButton.jsx
//インポートに追加
+ import PrimaryButton from '@/Components/PrimaryButton';

//DangerButtonをPrimaryButtonに変更
+<PrimaryButton onClick={confirmUserDeletion}>
+    登録
+</PrimaryButton>

スクリーンショット 2025-08-28 143448.png

登録ボタンの中で使用するContent(textarea), Category(select)の入力欄を作成する

1. ボタン内のcontent(textarea)を作成する
登録ボタンを押した際に出てくるTextInput.jsxファイルをコピーしてContent.jsxを作成する。
resources/js/Components/Content.jsx

//Content.jsx
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';

export default forwardRef(function TextInput(
    { type = 'text', className = '', isFocused = false, ...props },
    ref,
) {
    const localRef = useRef(null);

    useImperativeHandle(ref, () => ({
        focus: () => localRef.current?.focus(),
    }));

    useEffect(() => {
        if (isFocused) {
            localRef.current?.focus();
        }
    }, [isFocused]);

    return (
+        <textarea
            {...props}
            type={type}
            className={
                'rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 ' +
                className
            }
            ref={localRef}
        >
+        </textarea>
    );
});

//inputをtextareaに変更する

2. ボタン内のCategory(select)を作成する
登録ボタンを押した際に出てくるTextInput.jsxファイルをコピーしてCategory.jsxを作成する。
resources/js/Components/Category.jsx

//Category.jsx
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';

export default forwardRef(function TextInput(
+    { options = null, className = '', isFocused = false, ...props },
    ref,
) {
    const localRef = useRef(null);

    useImperativeHandle(ref, () => ({
        focus: () => localRef.current?.focus(),
    }));

    useEffect(() => {
        if (isFocused) {
            localRef.current?.focus();
        }
    }, [isFocused]);

    return (
+        <select
            {...props}
-            type={type}
            className={
                'rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 ' +
                className
            }
            ref={localRef}
        >
+        {options.map((option) => (
+            <option key={option} value={option}>{option}</option>
+        ))}
+        </select>
    );
});


//type='text'からoptions = nullに変更
//inputをselectに変更し、type={type}を削除する

options.mapの個所は親コンポーネント(RegisterButton.jsx)から受け取った値を繰り返し処理で表示している

登録ボタンを押した後にtitle,content,categoryを入力できるようにしていく。

1. titleの入力欄を追加

//RegisterButton.jsx
+ const titleInput = useRef();

-<InputLabel
-    htmlFor="title"
-    value="title"
-    className="sr-only"
-/>

<TextInput
    id="title"
    type="text"
    name="title"
    ref={titleInput}
    value={data.title}
    onChange={(e) =>
        setData('title', e.target.value)
    }
    className="mt-1 block w-3/4"
    isFocused
    placeholder="title"
/>

//passwordをtitleに変更。tyoeはtextに変更。
//passwordInputが変更されたのでtitleInputを変数として追加
//InputLabelは削除

スクリーンショット 2025-08-28 144125.png

2. contentの入力欄を追加
title入力欄をコピーしてしたに張り付けて作成

//RegisterButton.jsx
+ import Content from '@/Components/content';

+ const contentInput = useRef();

//title入力欄
<TextInput
/>

//content入力欄
<div className="mt-6">
    <Content
        id="content"
        type="text"
        name="content"
        ref={contentInput}
        value={data.content}
        onChange={(e) =>
            setData('content', e.target.value)
        }
        className="mt-1 block w-3/4"
        isFocused
        placeholder="content"
    />

    <InputError
        message={errors.password}
        className="mt-2"
    />
</div>
//インポートでContent使用可能に
//passwordをcontentに変更。tyoeはtextに変更。
//passwordInputが変更されたのでcontentInputを変数として追加

スクリーンショット 2025-08-28 145332.png

3. Categoryの入力欄を追加
title入力欄をコピーしてしたに張り付けて作成

//RegisterButton.jsx
+ import Category from '@/Components/Category';

+ const categoryInput = useRef();

//title入力欄
<TextInput
/>

//content入力欄
<Content
/>

//category入力欄
    <div className="mt-6">
    <Category
        id="category"
        name="category"
        ref={categoryInput}
        value={data.category}
        onChange={(e) =>
            setData('category', e.target.value)
        }
        className="mt-1 block w-3/4"
        isFocused
        placeholder="category"
        options={['','React', 'Laravel', 'Inertia']}
    />
    
    <InputError
        message={errors.password}
        className="mt-2"
    />
    </div>
//インポートでcategory使用可能に
//passwordをcategoryに変更。tyoeはCategoryコンポーネントどうようにSelectなので削除。
//passwordInputが変更されたのでcategoryInputを変数として追加
//optiponsで渡す値を追加

optiponsで子のコンポーネントに渡したい値を配列によって渡している。

スクリーンショット 2025-08-28 151315.png

4. 追加の文章、ボタンの文言、色を変更する

//RegisterButton.jsx
<h2 className="text-lg font-medium text-gray-900">
+    Todoリストに登録しますか?
</h2>

- <p className="mt-1 text-sm text-gray-600">
-    Once your account is deleted, all of its resources and
-    data will be permanently deleted. Please enter your
-    password to confirm you would like to permanently delete
-    your account.
-</p>

<div className="mt-6 flex justify-end">
    <SecondaryButton onClick={closeModal}>
+        戻る
    </SecondaryButton>

+    <PrimaryButton className="ms-3" disabled={processing}>
+        登録
+    </PrimaryButton>
</div>

スクリーンショット 2025-08-28 152220.png

Todoリストに追加できる処理を追加

//RegisterButton.jsx
const {
        data,
        setData,
        delete: destroy,
+       post,
        processing,
        reset,
        errors,
        clearErrors,
    } = useForm({
-        password: '',title: '',content: '',category: ''
+        title: '',content: '',category: ''
    });

    const confirmUserDeletion = () => {
        setConfirmingUserDeletion(true);
    };

-    const deleteUser = (e) => {
+    const RegisterTodo = (e) => {
        e.preventDefault();

-        delete(route('profile.destroy'), {
+        post(route('todos.store'), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

//Modelの部分
<Modal show={confirmingUserDeletion} onClose={closeModal}>
-    <form onSubmit={deleteUser} className="p-6">
+    <form onSubmit={RegisterTodo} className="p-6">

constにはpostで入力するためpostを追加

+       post,

useFormにはTodosに追加するtitle,content,categoryを追加。passwordは使わないので削除。

-        password: '',title: '',content: '',category: ''
+        title: '',content: '',category: ''

useFormは、Reactでフォーム(入力欄)の値やエラー、送信処理を簡単に管理できる便利な機能。

主な役割:
・入力値の管理(data, setData)
・サーバーへの送信(post, put, deleteなど)
・バリデーションエラーの管理(errors)
・送信中の状態(processing)

つまり「フォームの値を持っておき、送信やエラー処理もまとめてできる」仕組みで、Laravel Inertiaでよく使われる。

削除の変数だとおかしいので、登録の変数に変更(Modal)のほうも同じく反映させる。

-    const deleteUser = (e) => {
+    const RegisterTodo = (e) => {

-    <form onSubmit={deleteUser} className="p-6">
+    <form onSubmit={RegisterTodo} className="p-6">

コントローラーで作成した、store(保存)するルーティングに変更する。

-        delete(route('profile.destroy'), {
+        post(route('todos.store'), {

スクリーンショット 2025-08-28 155345.png
スクリーンショット 2025-08-28 155356.png
※登録できることを確認する。

投稿したものを編集できるボタンを作成する。

1. RegisterButton.jsxをコピーして、resources/js/Pages/Profile/Partials/UpdateButton.jsxを作成。

UpdateButton.jsx
import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react';
import { useRef, useState } from 'react';
import PrimaryButton from '@/Components/PrimaryButton';
import Content from '@/Components/content';
import Category from '@/Components/Category';

export default function RegisterButton({ className = '' }) {
    const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
    const passwordInput = useRef();
    const titleInput = useRef();
    const contentInput = useRef();
    const categoryInput = useRef();

    const {
        data,
        setData,
        delete: destroy,
        post,
        processing,
        reset,
        errors,
        clearErrors,
    } = useForm({
        title: '',content: '',category: ''
    });

    const confirmUserDeletion = () => {
        setConfirmingUserDeletion(true);
    };

    const RegisterTodo = (e) => {
        e.preventDefault();

        post(route('todos.store'), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

    const closeModal = () => {
        setConfirmingUserDeletion(false);

        clearErrors();
        reset();
    };

    return (
        <section className={`space-y-6 ${className}`}>
            <PrimaryButton onClick={confirmUserDeletion}>
                登録
            </PrimaryButton>

            <Modal show={confirmingUserDeletion} onClose={closeModal}>
                <form onSubmit={RegisterTodo} className="p-6">
                    <h2 className="text-lg font-medium text-gray-900">
                        Todoリストに登録しますか?
                    </h2>

                    <div className="mt-6">
                        <TextInput
                            id="title"
                            type="text"
                            name="title"
                            ref={titleInput}
                            value={data.title}
                            onChange={(e) =>
                                setData('title', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="title"
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>
                    <div className="mt-6">
                        <Content
                            id="content"
                            type="text"
                            name="content"
                            ref={contentInput}
                            value={data.content}
                            onChange={(e) =>
                                setData('content', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="content"
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>
                    <div className="mt-6">
                        <Category
                            id="category"
                            name="category"
                            ref={categoryInput}
                            value={data.category}
                            onChange={(e) =>
                                setData('category', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="category"
                            options={['','React', 'Laravel', 'Inertia']}
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>

                    <div className="mt-6 flex justify-end">
                        <SecondaryButton onClick={closeModal}>
                            戻る
                        </SecondaryButton>

                        <PrimaryButton className="ms-3" disabled={processing}>
                            登録
                        </PrimaryButton>
                    </div>
                </form>
            </Modal>
        </section>
    );
}

2. index.jsxの親コンポーネントからUpdateButtonボタンが押されたら、todos各値をpropsとしてUpdateButton.jsxコンポーネントに渡す。

//index.jsx
<tbody className='bg-white'>
    {todos.map((todos) => (
        <tr key={todos.id}>
            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
            <td className='border border-gray-400 p-2'>{todos.title}</td>
            <td className='border border-gray-400 p-2'>{todos.content}</td>
            <td className='border border-gray-400 p-2'>{todos.category}</td>
            <td className='border border-gray-400 p-2 text-center'>
+                <UpdateButton
+                    id={todos.id}
+                    title={todos.title}
+                    content={todos.content}
+                    category={todos.category}
+                />
            </td>
            <td className='border border-gray-400 p-2 text-center'>
            </td>
        </tr>
    ))}
</tbody>

これでUpdateButton.jsxの関数の引数に入れることによって使用できる。

3.Propsで受け取った値を編集できるようにUpdateButton.jsxを変更する。

//UpdateButton.jsx
import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react';
import { useRef, useState } from 'react';
import PrimaryButton from '@/Components/PrimaryButton';
import Content from '@/Components/content';
import Category from '@/Components/Category';

- export default function RegisterButton({ className = '' }) {
+ export default function UpdateButton({ id, title, content, category, className = '' }) {
    const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
    const passwordInput = useRef();
    const titleInput = useRef();
    const contentInput = useRef();
    const categoryInput = useRef();

    const {
        data,
        setData,
        delete: destroy,
        post,
+       put,
        processing,
        reset,
        errors,
        clearErrors,
    } = useForm({
-        title: '',content: '',category: ''
+        id: '',title: '',content: '',category: ''
    });

-    const confirmUserDeletion = () => {
-        setConfirmingUserDeletion(true);
-    };
+   const openEditModal = (id, title, content, category) => {
+        console.log('受け取ったid:', id); // ここで確認
+        setData({
+            id: id,
+            title: title,
+            content: content,
+            category: category
+        });
+        setEditModalOpen(true);
+    };

-    const RegisterTodo = (e) => {
+    const UpdateTodo = (e) => {
        e.preventDefault();

-        post(route('todos.store'), {
+        put(route('todos.update', data.id), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

    const closeModal = () => {
-        setConfirmingUserDeletion(false);
+        setEditModalOpen(false);

        clearErrors();
        reset();
    };

    return (
        <section className={`space-y-6 ${className}`}>
-            <PrimaryButton onClick={confirmUserDeletion}>
+            <PrimaryButton onClick={() => openEditModal(id, title, content, category)}>
                登録
            </PrimaryButton>

            <Modal show={confirmingUserDeletion} onClose={closeModal}>
                <form onSubmit={RegisterTodo} className="p-6">
                    <h2 className="text-lg font-medium text-gray-900">
-                        Todoリストに登録しますか?
+                        Todoリストを変更しますか?
                    </h2>

                    <div className="mt-6">
                        <TextInput
                            id="title"
                            type="text"
                            name="title"
                            ref={titleInput}
                            value={data.title}
                            onChange={(e) =>
                                setData('title', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="title"
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>
                    <div className="mt-6">
                        <Content
                            id="content"
                            type="text"
                            name="content"
                            ref={contentInput}
                            value={data.content}
                            onChange={(e) =>
                                setData('content', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="content"
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>
                    <div className="mt-6">
                        <Category
                            id="category"
                            name="category"
                            ref={categoryInput}
                            value={data.category}
                            onChange={(e) =>
                                setData('category', e.target.value)
                            }
                            className="mt-1 block w-3/4"
                            isFocused
                            placeholder="category"
                            options={['','React', 'Laravel', 'Inertia']}
                        />

                        <InputError
                            message={errors.password}
                            className="mt-2"
                        />
                    </div>

                    <div className="mt-6 flex justify-end">
                        <SecondaryButton onClick={closeModal}>
                            戻る
                        </SecondaryButton>

                        <PrimaryButton className="ms-3" disabled={processing}>
                            登録
                        </PrimaryButton>
                    </div>
                </form>
            </Modal>
        </section>
    );
}

解説

---処理の流れ---

1.編集ボタンを押すとindex.jsxで渡した値を関数であるUpdateButtonの引数に設定したものがopenEditModalに渡る。

2.openEditModalで受け取った値はsetDataに入って、編集画面の入力項目に追加される。(Todosで登録した値)

3.表示された後は、setEditModalOpen(true)でeditModalOpenに渡るため、編集フォームが表示される。

4.編集フォームから送られた値は変数UpdateTodoに渡される。

5.UpdateTodo変数ではLaravelのコントローラーで設定したputのupdateの関数の処理が行われて編集内容が上書きされる。

投稿したものを削除できるボタンを作成する。

1.index.jsxに削除ボタンのコンポーネントを追加する。

//DeleteButton.jsx
<tbody className='bg-white'>
    {todos.map((todos) => (
        <tr key={todos.id}>
            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
            <td className='border border-gray-400 p-2'>{todos.title}</td>
            <td className='border border-gray-400 p-2'>{todos.content}</td>
            <td className='border border-gray-400 p-2'>{todos.category}</td>
            <td className='border border-gray-400 p-2 text-center'>
                <UpdateButton
                    id={todos.id}
                    title={todos.title}
                    content={todos.content}
                    category={todos.category}
                />
            </td>
            <td className='border border-gray-400 p-2 text-center'>
+             <DeleteButton
+                    id={todos.id}
+              />
            </td>
        </tr>
    ))}
</tbody>

2.resources/js/Pages/Profile/Partials/DeleteButton.jsxで作成したファイルに追加していく。

DeleteButton.jsx
import DangerButton from '@/Components/DangerButton';
import { useForm } from '@inertiajs/react';

export default function DeleteButton({ id, className = '' }) {
    const {
        delete: destroy,
        reset,
    } = useForm({
        id:''
    });

    const DeleteTodo = (id) => {
        if (!window.confirm('削除しますか?')) {
        return; // キャンセルなら何もせず戻る
    }
        destroy(route('todos.destroy', id), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

    return (
        <section className={`space-y-6 ${className}`}>
            <DangerButton onClick={() => DeleteTodo(id)}>
                 削除
            </DangerButton>
        </section>
    );
}

2-1 UpdateButton.jsx同様に index.jsxのPropsで受け取った値をDeleteButtonの引数に設定。

2-2 return部分で削除ボタンがクリックされたら、DeleteTodoに値を渡す。

2-2 DeleteTodoで受け取った値はアラートでさ---処理の流れ---

1.編集ボタンを押すとindex.jsxで渡した値を関数であるUpdateButtonの引数に設定したものがopenEditModalに渡る。

2.openEditModalで受け取った値はsetDataに入って、編集画面の入力項目に追加される。(Todosで登録した値)

3.表示された後は、setEditModalOpen(true)でeditModalOpenに渡るため、編集フォームが表示される。

4.編集フォームから送られた値は変数UpdateTodoに渡される。

5.UpdateTodo変数ではLaravelのコントローラーで設定したputのupdateの関数の処理が行われて編集内容が上書きされる。

投稿したものを削除できるボタンを作成する。

1.index.jsxに削除ボタンのコンポーネントを追加する。

//DeleteButton.jsx
<tbody className='bg-white'>
    {todos.map((todos) => (
        <tr key={todos.id}>
            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
            <td className='border border-gray-400 p-2'>{todos.title}</td>
            <td className='border border-gray-400 p-2'>{todos.content}</td>
            <td className='border border-gray-400 p-2'>{todos.category}</td>
            <td className='border border-gray-400 p-2 text-center'>
                <UpdateButton
                    id={todos.id}
                    title={todos.title}
                    content={todos.content}
                    category={todos.category}
                />
            </td>
            <td className='border border-gray-400 p-2 text-center'>
+             <DeleteButton
+                    id={todos.id}
+              />
            </td>
        </tr>
    ))}
</tbody>

2.resources/js/Pages/Profile/Partials/DeleteButton.jsxで作成したファイルに追加していく。

DeleteButton.jsx
import DangerButton from '@/Components/DangerButton';
import { useForm } from '@inertiajs/react';

export default function DeleteButton({ id, className = '' }) {
    const {
        delete: destroy,
        reset,
    } = useForm({
        id:''
    });

    const DeleteTodo = (id) => {
        if (!window.confirm('削除しますか?')) {
        return; // キャンセルなら何もせず戻る
    }
        destroy(route('todos.destroy', id), {
            preserveScroll: true,
            onSuccess: () => closeModal(),
            onError: () => passwordInput.current.focus(),
            onFinish: () => reset(),
        });
    };

    return (
        <section className={`space-y-6 ${className}`}>
            <DangerButton onClick={() => DeleteTodo(id)}>
                 削除
            </DangerButton>
        </section>
    );
}

2-1 UpdateButton.jsx同様に index.jsxのPropsで受け取った値をDeleteButtonの引数に設定。

2-2 return部分で削除ボタンがクリックされたら、DeleteTodoに値を渡す。

2-3 DeleteTodoで受け取った値はアラートで削除するかどうかの判定を出す。
スクリーンショット 2025-08-28 183523.png

2-4 OKをクリックすると、Laravelのコントローラーで作成したdestroy関数の処理が実行されて削除される。

完成したので実行した際のメッセージを表示して、ボタンの色も変更しておく。

1. 登録や削除した際のボタンを表示させる。

//index.jsx
export default function Dashboard({auth, todos, message}) {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800">
                    Todo Page
                </h2>
            }
        >
            <Head title="Dashboard" />
+            {message && <div className="mb-4 p-2 bg-green-100 text-green-800 text-center rounded">{message}</div>}
            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                            <RegisterButton />
                        <div>
                            <table className='w-full bg-gray-100 mt-2'>
                                <thead className='bg-blue-200'>
                                    <tr className='text-green-600'>
                                        <th className='border border-gray-400 p-2'>ID</th>
                                        <th className='border border-gray-400 p-2'>Title</th>
                                        <th className='border border-gray-400 p-2'>Content</th>
                                        <th className='border border-gray-400 p-2'>Category</th>
                                        <th className='border border-gray-400 p-2'></th>
                                        <th className='border border-gray-400 p-2'></th>
                                    </tr>
                                </thead>
                                <tbody className='bg-white'>
                                    {todos.map((todos) => (
                                        <tr key={todos.id}>
                                            <td className='border border-gray-400 p-2 text-center'>{todos.id}</td>
                                            <td className='border border-gray-400 p-2'>{todos.title}</td>
                                            <td className='border border-gray-400 p-2'>{todos.content}</td>
                                            <td className='border border-gray-400 p-2'>{todos.category}</td>
                                            <td className='border border-gray-400 p-2 text-center'>
                                                <UpdateButton
                                                    id={todos.id}
                                                    title={todos.title}
                                                    content={todos.content}
                                                    category={todos.category}
                                                />
                                            </td>
                                            <td className='border border-gray-400 p-2 text-center'>
                                                <DeleteButton
                                                    id={todos.id}
                                                />
                                            </td>
                                        </tr>
                                    ))}
                                </tbody>
                            </table>
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

&&が必要なのか?

「{message && <div>...</div>}」

の書き方は、「messageがあるときだけ、メッセージ表示用のdivを描画する」という意味なので「&&」が必要になってくる。

もしmessageが空(nullやundefined)の場合は、何も表示されない。Reactではこの書き方が一般的。

&&は「条件付きレンダリング」に使うので、メッセージがあるときだけ表示したい場合は必要。

スクリーンショット 2025-08-29 093812.png

2. ボタンの色を変更していく。
resources/js/Componentsのディレクトリに「BlueButoon.jsx」「GreenButoon.jsx」を作成する。

作成については、既にあるDangerButtonを参考にする。

BlueButoon.jsx
export default function DangerButton({
    className = '',
    disabled,
    children,
    ...props
}) {
    return (
        <button
            {...props}
            className={
                `inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700 ${
                    disabled && 'opacity-25'
                } ` + className
            }
            disabled={disabled}
        >
            {children}
        </button>
    );
}

GreenButoon.jsx
export default function DangerButton({
    className = '',
    disabled,
    children,
    ...props
}) {
    return (
        <button
            {...props}
            className={
                `inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700 ${
                    disabled && 'opacity-25'
                } ` + className
            }
            disabled={disabled}
        >
            {children}
        </button>
    );
}

両方ともにClassNameのbg-○○-600の「○○」に色を当てはめるだけ

2-1 使用するファイルにインポートしてスタイルを当てる。

RegisterButton = BlueButton
UpdateButton = GreenButton

//RegisterButton.jsx
+ import BlueButton from '@/Components/BlueButton';

//Topページの表示
+ <BlueButton onClick={confirmUserDeletion}>
+     登録
+ </BlueButton>

//投稿ページの表示
+ <BlueButton className="ms-3" disabled={processing}>
+     登録
+ </BlueButton>

//UpdateButton.jsx
+ import GreenButton from '@/Components/GreenButton';
+ import BlueButton from '@/Components/BlueButton';

+ <GreenButton onClick={() => openEditModal(id, title, content, category)}>
+     編集
+ </GreenButton>

+ <BlueButton className="ms-3" disabled={processing}>
+     更新
+ </BlueButton>

スクリーンショット 2025-08-29 095538.png

これで完成。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?