1️⃣

【Laravel10】 Inertia + React で「追加・変更・削除・閲覧」機能をつくる(その1)

2024/07/23に公開

概要

以前、Inertiaを使ったLaravel 10 + Reactのベストチュートリアルで、Laravel 10を用いて、Inertia + Reactの動作環境とログイン機能をつくってみましたが、今回もその流れで基本的な「追加・変更・削除・閲覧」部分(いわゆる CRUD)をつくってみることにしました。
CRUDを理解すれば、よくある基本的な機能の開発は問題なく行うことができるようになるので、教材として最適です。

バージョン情報

  • Laravel 10.0.0
  • 手元の作業PC: Apple M1 Pro
  • Docker: 20.10.21
    • イメージ: php:8.1-apache
    • イメージ: mariadb:10.3
    • イメージ: phpmyadmin:latest
    • イメージ: mailhog/mailhog:latest
  • Laravel: 10.1.3
  • React: 18.2.0
  • Inertia.js v1.0
  • Docker-compose: 2.13.0

手順

1. モデル側の準備

  • modelと同時に、migration(-m)、seeder(-s)、factory(-f)、controller(-c)を同時に作ります。
php artisan make:model Post -msfc

ログ

   INFO  Model [app/Models/Post.php] created successfully.  

   INFO  Factory [database/factories/PostFactory.php] created successfully.  

   INFO  Migration [database/migrations/2023_02_24_164822_create_posts_table.php] created successfully.  

   INFO  Seeder [database/seeders/PostSeeder.php] created successfully.  

   INFO  Controller [app/Http/Controllers/PostController.php] created successfully.

モデル編集

app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'body',
    ];
}

マイグレーションファイル編集

テーブル定義を記載する。

2023_02_24_164822_create_posts_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()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title', 100)->comment('タイトル');
            $table->text('body')->comment('本文');
            $table->timestamps();
        });
    }

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

シーダーファイルを編集

シードデータを50件作る

database/seeders/PostSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Post::factory(50)->create();
    }
}

ファクトリーを編集

database/factories/PostFactory.php
<?php

namespace Database\Factories;

use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
 */
class PostFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Post::class;

    private static $sequence = 1;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $sequence = self::$sequence++;

        return [
            'title' => 'タイトル' . $sequence,
            'body' => $this->faker->realText(1000),
        ];
    }
}

fakerの言語を日本語にする

config/app.php
...
    'faker_locale' => 'ja_JP',
...

DatabaseSeederにPostSeederを登録

Seederは作成しただけでは有効になりませんので、DatabaseSeeder.phpへ登録します。
$this->call(PostSeeder::class);を1行追加

database/seeders/DatabaseSeeder.php
<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        $this->call(UserSeeder::class);
        $this->call(PostSeeder::class); // 追加
    }
}

DBを初期化

以下のコマンドで、テーブルを全て作り直してマイグレーション&シード投入

php artisan migrate:fresh --seed

実行後にDBを確認すると、postsテーブルが作成されて、シードデータが投入されていることがわかる。

2. ルーティング

routes/web.php
// 省略
use App\Http\Controllers\PostController;
// 省略
Route::group([
    'prefix' => 'posts',
    'as' => 'posts.',
], function () {

    // route('posts.view.index')
    Route::get('/', [PostController::class, 'index'])
    ->name('view.index');

    // route('posts.view.new')
    Route::get('/new', [PostController::class, 'new'])
    ->name('view.new');

    // route('posts.input.store')
    Route::post('/', [PostController::class, 'store'])
    ->name('input.store');

    // route('posts.view.edit', id)
    Route::get('{id}/edit', [PostController::class, 'edit'])
    ->name('view.edit');

    // route('posts.view.show', id)
    Route::get('{id}', [PostController::class, 'show'])
    ->name('view.show');

    // route('posts.input.update', id)
    Route::put('/{id}', [PostController::class, 'update'])
    ->name('input.update');

    // route('posts.input.destroy', id)
    Route::delete('/{id}', [PostController::class, 'destroy'])
    ->name('input.destroy');
});

// 省略

3. コントローラー側の準備

はじめに、中身のないアクションだけ作成する

app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
    }

    public function new()
    {
    }

    public function store()
    {
    }

    public function show()
    {
    }

    public function edit()
    {
    }

    public function update()
    {
    }

    public function destroy()
    {
    }
}

PostController::indexを実装

app/UseCases/Post/IndexAction.phpを作成

コントローラに書く処理をできるだけ薄くするために、UseCases配下にドメインロジックを記載していく。

mkdir app/UseCases/Post
touch app/UseCases/Post/IndexAction.php
app/UseCases/IndexAction.php
<?php

namespace App\UseCases\Post;

use App\Models\Post;

class IndexAction
{
    public function __invoke(): array
    {
        $posts = Post::orderBy('created_at', 'desc')
        ->paginate(5)
        ->through(function($row){
            // 15文字を超える場合、...で省略
            if (mb_strlen($row->body, 'UTF-8') > 15) {
                $row->body = mb_substr($row->body, 0, 12, 'UTF-8') . '...';
            }
            
            return $row;
        });

        return [
            'title' => '【Laravel10】 Inertia + Reactで CRUD',
            'posts' => $posts,
            'message' => session('message'),
        ];
    }
}

PostController::indexを実装

indexアクションを実装します。Laravel単体でビューを返す場合はview($view = null, $data = [], $mergeData = [])を呼んでいましたが、これがInertia::render($component, $props = [])になっただけです。

app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\UseCases\Post\IndexAction;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class PostController extends Controller
{
    public function index(IndexAction $action): Response
    {
        return Inertia::render('Post/Index', $action());
    }

    public function create()
    {
    }

    public function store()
    {
    }

    public function show()
    {
    }

    public function edit()
    {
    }

    public function update()
    {
    }

    public function destroy()
    {
    }
}

4. ビュー側の準備 (コンポーネント)

PostController::indexが読み込むコンポーネントを作成

resources/js/Pages/Post/Index.jsxを作成

mkdir resources/js/Pages/Post 
touch resources/js/Pages/Post/Index.jsx
resources/js/Pages/Post/Index.jsx
import React from "react";
import { Link, useForm } from '@inertiajs/react';
import Pagination from "@/Components/Pagination";

export default function Index(props) {
    const { delete: destroy } = useForm({
        page: props.posts.current_page, // リクエストボディにpageを追加
    });

    // Data
    const { message } = props;

    // Methods
    const onDelete = id => {

        if(confirm('削除します。よろしいですか?')) {

            const url = route('posts.input.destroy', id);

            destroy(url, { preserveScroll: true });

        }

    };

    return (
        <div className="p-5">
            <h1 className="font-bold">{props.title}</h1>
            {message && <div id="message" className="mt-2 text-green-700  bg-green-100 p-3 rounded-lg">{message}</div>}
            <div className="text-right p-3 mb-2">
                <Link className="text-white bg-green-700 rounded-lg text-sm px-4 py-2 mr-2" href={route('posts.view.new')}>+ 追加する</Link>
            </div>
            <table className="w-full bg-gray-100">
                <thead className="bg-blue-100">
                    <tr>
                        <th>ID</th>
                        <th>タイトル</th>
                        <th>操作</th>
                    </tr>
                </thead>
                <tbody>
                    {props.posts.data.map(post => (
                        <tr key={post.id}>
                            <td className="p-2 border">{post.title}</td>
                            <td className="p-2 border">{post.body}</td>
                            <td className="p-2 border">
                                <Link
                                    className="text-white bg-gray-400 rounded-lg text-sm px-4 py-2 mr-2"
                                    href={route('posts.view.show', post.id)}>
                                    確認
                                </Link>
                                <Link
                                    className="text-white bg-blue-700 rounded-lg text-sm px-4 py-2 mr-2"
                                    href={route('posts.view.edit', post.id)}>
                                    変更
                                </Link>
                                <button
                                    className="text-white bg-red-700 rounded-lg text-sm px-4 py-2 mr-2"
                                    onClick={() => onDelete(post.id)}>
                                    削除
                                </button>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>

            <Pagination data={props.posts} />
        </div>
    );
}

resources/js/Components/Pagination.jsxを作成

resources/js/Components/Pagination.jsx
import React from 'react';
import NavLink from "@/Components/NavLink";

export default function Pagination({ data }) {

    return (
        <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
            <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
                <div>
                    <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
                        {data.total > 0 && data.links.map(link => (
                            <NavLink
                                key={link.label}
                                href={link.url}
                                active={link.active}>
                                <span dangerouslySetInnerHTML={{ __html: link.label }} />
                            </NavLink>
                        ))}
                    </nav>
                </div>
            </div>
        </div>
    );

}

確認

npm run dev をして、http://localhost/posts にアクセスすると、記事が5件表示されていることがわかります。
まだ、「追加する」「確認」「変更」「削除」ボタンを押してもCRUDは起こりませんが問題ないです。

まとめ

今回は、indexアクションのみの実装となりましたが、続編に分けて残りの実装を記載しています。
続編 【Laravel10】 Inertia + React で「追加・変更・削除・閲覧」機能をつくる(その2)

関連記事

Discussion