久しぶりの投稿になりますが、Laravel10+Inertia+Reactでログイン・追加・更新・削除機能を作成しました。フロント側のReactとLaravelでCRUDシステムを作成するには、従来、Laravel側としては、axiosを経由して、JSONデータを渡したり受け取ったりのみの裏方仕事のような地味な役割?を担っていたかと思いますが、InertiaのおかげでLaravel側でフロント側もバックエンド側も、別のプロジェクトとしてではなく、一つのプロジェクトとして開発できるようになっています。今回は、本のタイトル・内容・カテゴリーを登録・更新・削除するCRUDを作成しました。
作成する上でのポイントがいくつかあります。Scriptのメイン関数への引数の渡し方がネットで見られるLaravel9での情報とは異なっております。また、TextareaInput.jsxやBluButton.jsxなどの独自ファイルをComponents内に作成しました。モーダルウインドウは、DeleteUserForm.jsxを参考にして登録フォーム、更新フォームを作成しました。
コントローラーへの値の受け渡しは、従来のaxios経由とは異なり、非常にあっさりとしており、開発効率はとても良いです。また、認証の処理もjwt authなど使うこともなく、特に意識することもなく出来てしまいますので、淡白に開発ができます。
なお、メインファイルのIndex.jsxの記載でブログとYoutubeで異なる箇所がありますので、「最後に」の後にYoutubeでのコードも残しておきます。
準備
Laravelのプロジェクト作成
Laravelプロジェクトを作成します。
composer create-project laravel/laravel Laravelのプロジェクト名
※envファイルでDBの設定はしておきます。普通のmySQLの設定と同じです。
Laravel Breezeのインストール
認証のための機能Breezeのインストールをします。
composer require laravel/breeze –dev
※devの前のハイフンは二つ必要です。以下に似たような箇所があれば、同じようにご判断ください。
Reactのインストール
上のBreezeのインストールと同じ階層で行います。従来のReactとLaravelでは別の階層(別のURLとなるように)で、別々のプロジェクトを作成していましたが、同じ階層で下のコマンドを打ちます。
php artisan breeze:install react
これでReactとInertiaが導入されます。
app.phpの編集
ダミーデータ作成のために、日本語化しておきます。
‘locale’ => ‘ja’,
‘fallback_locale’ => ‘ja’,
‘faker_locale’ => ‘ja_JP’,
テーブルの作成、ダミーデータの挿入
Booksモデルを作成します。
php artisan make:model Books -mcrf
モデルBooks.phpだけではなく、ファクトリーBooksFactory.phpとコントローラーBooksController.phpとマイグレーションファイルが作成されます。
マイグレーションファイルを編集します。下を追加します。
$table->string(‘title,12);
$table->text(‘content’);
$table->string(‘category’,10);
Books.phpでフィールドをホワイトリスト化します。
protected $fillable = [‘title’,’content’,’category’];
を追加します。
return [
‘title’ => $this -> faker -> text(12),
‘content’ => $this -> faker -> text(20),
‘category’ => $this -> faker -> randomElement($categorys)
];
run内に
Books::factory(7)->create();
を記入します。
Booksコントローラー
コントローラーの編集です。普通ですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
public function index() { $user = User::find(auth()->id()); $books = Books::all(); return Inertia::render('Books/Index',['books'=>$books,'user_id'=>$user->id,'user_name'=>$user->name,'message' => session('message')]); } public function store(Request $request) { $request-> validate([ 'title' => 'required|max:20', 'content' => 'required|max:100', 'category' => 'required|max:10', ]); $book = new Books($request->input()); $book->save(); return redirect('books')->with([ 'message' => '登録しました', ]); } public function update(Request $request, $id) { $book = Books::find($id); $book->fill($request->input())->saveOrFail(); return redirect('books')->with([ 'message' => '更新しました', ]); } public function destroy($id) { $book = Books::find($id); $book->delete(); return redirect('books')->with([ 'message' => '削除しました', ]); } |
ルーティング
web.phpに追記します。
Route::get(‘/books’, [BooksController::class, ‘index’])->name(‘books.index’);
Route::post(‘/books/store’, [BooksController::class, ‘store’])->name(‘books.store’);
Route::put(‘/books/update/{id}’, [BooksController::class, ‘update’])->name(‘books.update’);
Route::delete(‘/books/destroy/{id}’, [BooksController::class, ‘destroy’])->name(‘books.destroy’);
を追記します。これも、普通のLaravel?と一緒です。
上部メニューの作成
Dashboardの下に
<NavLinkhref={route(‘books.index’)}active={route().current(‘books.index’)}>
Books
</NavLink>
</div>
Index.jsx メインファイルの作成
booksテーブルの一覧表示
Dashboard.jsxをコピーして貼り付けて、編集していきます。
laravel9+Reactの他の情報を見ていますと、メイン関数への引数として、props を渡しているのを見かけますが、Laravel10では、デフォルトの引数がauthでした。引数として、propsではなくて、 auth と books を渡してあげます。export default function Dashboard({ auth,books }) の箇所です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head } from '@inertiajs/react'; export default function Dashboard({ auth,books }) { return ( <AuthenticatedLayout user={auth.user} header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Books</h2>} > <Head title="Dashboard" /> <div className="py-12"> <div className="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div> <table className="w-full bg-gray-100 mt-2"> <thead className="bg-blue-100"> <tr className='text-green-600'> <th className='px-2 py-2 border border-gray-400'>#</th> <th className='px-2 py-2 border border-gray-400'>タイトル</th> <th className='px-2 py-2 border border-gray-400'>内容</th> <th className='px-2 py-2 border border-gray-400'>カテゴリー</th> <th className='px-2 py-2 border border-gray-400'></th> <th className='px-2 py-2 border border-gray-400'></th> </tr> </thead> <tbody className='bg-white'> {books.map((book) => ( <tr key={book.id}> <td className='border border-gray-400 px-2 py-2 text-center'>{book.id}</td> <td className='border border-gray-400 px-2 py-2'>{book.title}</td> <td className='border border-gray-400 px-2 py-2'>{book.content}</td> <td className='border border-gray-400 px-2 py-2'>{book.category}</td> <td className='border border-gray-400 px-2 py-2'> </td> <td className='border border-gray-400 px-2 py-2'> </td> </tr> ))} </tbody> </table> </div> </div> </div> </AuthenticatedLayout> ); } |
Modalの導入
コントローラーのstoreメソッドにフロント側から値を渡す処理をします。
.\Profile\Partials\DeleteUserForm.jsx から import部分、DangerButton・Modal部分、return下のconstではじまる定義部分をコピーして,Index.jsxに貼り付けます。
「DELETE ACCOUNT」ボタンが設置されており、クリックするとモーダルウインドウが表示されます。
import部分
1 2 3 4 5 6 7 8 9 10 |
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head } from '@inertiajs/react'; import { useRef, useState } from 'react'; 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'; |
javascript部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false); const passwordInput = useRef(); const { data, setData, delete: destroy, processing, reset, errors, } = 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); reset(); }; |
Modal部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<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="ml-3" disabled={processing}> Delete Account </DangerButton> </div> </form> </Modal> |
フォーム部品の作成
モーダルウインドウ内のフォームに、タイトル、内容、カテゴリーを入力するボックスが必要となります。textInput.jsxは最初から有りますが、textareaとselectに相当するパーツがありませんので、これを作成します。
./js/components/にTextInput.jsxファイルがあるので、これを複製します。ファイル名をTextarea.jsxとSelect.jsxとします。
Textarea.jsxの作成
<Input の箇所をtextareaにします。閉じタグであったところを、</textarea> とします。
1 2 3 4 5 6 7 8 9 10 |
<textarea {...props} type={type} className={ 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm ' + className } ref={input} > </textarea> |
Select.jsxの作成
1 2 3 4 5 |
> {options.map((option) => ( <option value={option} key={option}>{option}</option> ))} </select> |
Index.jsxの編集
import Select from ‘@/Components/Select’;
フォーム内でref=で出てくる関数を定義します。
const contentInput = useRef();
const categoryInput = useRef();
Modal内
showやonsubmitで実行される関数は名前を書き換えております。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
<Modal show={confirmingBookInsert} onClose={closeModal}> <form onSubmit={insertBook} className="p-6"> <h2 className="text-lg font-medium text-gray-900"> Are you sure you want to insert new item? </h2> <p className="mt-1 text-sm text-gray-600"> </p> <div className="mt-6"> <InputLabel htmlFor="text" 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" /> <InputError message={errors.title} className="mt-2" /> </div> <div className="mt-6"> <InputLabel htmlFor="text" value="content" className="sr-only" /> <TextareaInput 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" placeholder="content" > </TextareaInput> <InputError message={errors.content} className="mt-2" /> </div> <div className="mt-6"> <Select id="category" name="category" ref={categoryInput} value={data.category} required='required' onChange={(e) => setData('category', e.target.value)} className="mt-1 block w-3/4" options={['','React','Vue','Laravel']}></Select> <InputError message={errors.category} className='mt-2'></InputError> </div> <div className="mt-6 flex justify-end"> <SecondaryButton onClick={closeModal}>Cancel</SecondaryButton> <PrimaryButton className="ml-3" disabled={processing}> 登録 </PrimaryButton> </div> </form> </Modal> <Modal show={confirmingBookUpdate} onClose={closeModal}> <form onSubmit={updateBook} className="p-6"> <h2 className="text-lg font-medium text-gray-900"> Are you sure you want to update this item? </h2> <p className="mt-1 text-sm text-gray-600"> </p> <div className="mt-6"> <InputLabel htmlFor="text" 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" /> <InputError message={errors.title} className="mt-2" /> </div> <div className="mt-6"> <InputLabel htmlFor="text" value="content" className="sr-only" /> <TextareaInput 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" placeholder="content" > </TextareaInput> <InputError message={errors.content} className="mt-2" /> </div> <div className="mt-6"> <Select id="category" name="category" ref={categoryInput} value={data.category} required='required' onChange={(e) => setData('category', e.target.value)} className="mt-1 block w-3/4" options={['','React','Vue','Laravel']}></Select> <InputError message={errors.category} className='mt-2'></InputError> </div> <div className="mt-6 flex justify-end"> <SecondaryButton onClick={closeModal_u}>Cancel</SecondaryButton> <PrimaryButton className="ml-3" disabled={processing}> 更新 </PrimaryButton> </div> </form> </Modal> |
Reactの核心部分の編集
1 2 3 4 5 6 7 8 9 10 |
const { data, setData, delete: destroy, processing, reset, errors, } = useForm({ password: '', }); |
以下のようにします。post,put,を追加して、title,content,categoryを初期化しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const { data, setData, delete: destroy,post,put, processing, reset, errors, } = useForm({ password: '', title: '', content: '', category: '', }); |
reactの核心部分として、以下 がーっと編集します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
const confirmBookInsert = () => { setData({title:'',content:'',category:''}); setConfirmingBookInsert(true); }; const insertBook = (e) => { e.preventDefault(); post(route('books.store'), { preserveScroll: true, onSuccess: () => closeModal(), onError: () => titleInput.current.focus(), onFinish: () => reset(), }); }; const confirmBookUpdate = (id,title,content,category) => { setData({id:id,title:title,content:content,category:category}); setConfirmingBookUpdate(true); }; const updateBook = (e) => { e.preventDefault(); put(route('books.update',data.id), { preserveScroll: true, onSuccess: () => closeModal_u(), onError: () => titleInput.current.focus(), onFinish: () => reset(), }); }; const closeModal = () => { setConfirmingBookInsert(false); reset(); }; const closeModal_u = () => { setConfirmingBookUpdate(false); reset(); }; const deletebook = (id) => { destroy(route('books.destroy',id), { preserveScroll: true, }); }; |
動作確認
追加
更新
削除
最後に
Youtubeでのコード
Index.jsxファイル※youtube版
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head } from '@inertiajs/react'; import { useRef, useState } from 'react'; 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 PrimaryButton from '@/Components/PrimaryButton'; import BlueButton from '@/Components/BlueButton'; import GreenButton from '@/Components/GreenButton'; import TextareaInput from '@/Components/TextareaInput'; import Select from '@/Components/Select'; export default function Dashboard({ auth,books,message }) { const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false); const [confirmingBookUpdate, setConfirmingBookUpdate] = useState(false); const passwordInput = useRef(); const titleInput = useRef(); const contentInput = useRef(); const categoryInput = useRef(); const { data, setData, delete: destroy,post,put, processing, reset, errors, } = useForm({ password: '',title:'',content:'',category:'' }); const confirmUserDeletion = () => { setConfirmingUserDeletion(true); }; const confirmBookUpdate = (id,title,content,category) => { setData({id:id,title:title,content:content,category:category}); setConfirmingBookUpdate(true); }; const deleteUser = (e) => { e.preventDefault(); post(route('books.store'), { preserveScroll: true, onSuccess: () => closeModal(), onError: () => passwordInput.current.focus(), onFinish: () => reset(), }); }; const updateBook = (e) => { e.preventDefault(); put(route('books.update',data.id), { preserveScroll: true, onSuccess: () => closeModal_u(), onError: () => passwordInput.current.focus(), onFinish: () => reset(), }); }; const deleteBook = (id) => { destroy(route('books.destroy',id), { preserveScroll: true, onFinish: () => reset(), }); }; const closeModal = () => { setConfirmingUserDeletion(false); reset(); }; const closeModal_u = () => { setConfirmingBookUpdate(false); reset(); }; return ( <AuthenticatedLayout user={auth.user} header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Books</h2>} > <Head title="Dashboard" /> <div className="py-12"> <div className="max-w-7xl mx-auto sm:px-6 lg:px-8"> <BlueButton onClick={confirmUserDeletion}>登録</BlueButton> <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 insert new book? </h2> <p className="mt-1 text-sm text-gray-600"> </p> <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.title} className="mt-2" /> </div> <div className="mt-6"> <TextareaInput 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" placeholder="content" > </TextareaInput> <InputError message={errors.content} className="mt-2" /> </div> <div className="mt-6"> <Select id="category" name="category" ref={categoryInput} value={data.category} onChange={(e) => setData('category', e.target.value)} className="mt-1 block w-3/4" placeholder="category" options={['','React','Vue','Laravel']} > </Select> <InputError message={errors.category} className="mt-2" /> </div> <div className="mt-6 flex justify-end"> <SecondaryButton onClick={closeModal}>Cancel</SecondaryButton> <BlueButton className="ml-3" disabled={processing}> 登録する </BlueButton> </div> </form> </Modal> <Modal show={confirmingBookUpdate} onClose={closeModal_u}> <form onSubmit={updateBook} className="p-6"> <h2 className="text-lg font-medium text-gray-900"> Are you sure you want to update this book? </h2> <p className="mt-1 text-sm text-gray-600"> </p> <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.title} className="mt-2" /> </div> <div className="mt-6"> <TextareaInput 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" placeholder="content" > </TextareaInput> <InputError message={errors.content} className="mt-2" /> </div> <div className="mt-6"> <Select id="category" name="category" ref={categoryInput} value={data.category} onChange={(e) => setData('category', e.target.value)} className="mt-1 block w-3/4" placeholder="category" options={['','React','Vue','Laravel']} > </Select> <InputError message={errors.category} className="mt-2" /> </div> <div className="mt-6 flex justify-end"> <SecondaryButton onClick={closeModal_u}>Cancel</SecondaryButton> <BlueButton className="ml-3" disabled={processing}> 更新する </BlueButton> </div> </form> </Modal> {message && <div className="mt-2 text-blue-900 bg-green-100 p-3 rounded-lg text-center font-bold">{message}</div>} <div> <table className="w-full bg-gray-100 mt-2"> <thead className="bg-blue-100"> <tr className='text-green-600'> <th className='px-2 py-2 border border-gray-400'>#</th> <th className='px-2 py-2 border border-gray-400'>TItle</th> <th className='px-2 py-2 border border-gray-400'>Content</th> <th className='px-2 py-2 border border-gray-400'>Category</th> <th className='px-2 py-2 border border-gray-400'></th> <th className='px-2 py-2 border border-gray-400'></th> </tr> </thead> <tbody className='bg-white'> {books.map((book) => ( <tr key={book.id}> <td className='border border-gray-400 px-2 py-2 text-center'>{book.id}</td> <td className='border border-gray-400 px-2 py-2'>{book.title}</td> <td className='border border-gray-400 px-2 py-2'>{book.content}</td> <td className='border border-gray-400 px-2 py-2'>{book.category}</td> <td className='border border-gray-400 px-2 py-2 text-center'> <GreenButton onClick={() =>confirmBookUpdate(book.id,book.title,book.content,book.category)}> 編集 </GreenButton> </td> <td className='border border-gray-400 px-2 py-2 text-center'> <DangerButton onClick={() => deleteBook(book.id)}> 削除 </DangerButton> </td> </tr> ))} </tbody> </table> </div> </div> </div> </AuthenticatedLayout> ); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { forwardRef, useEffect, useRef } from 'react'; export default forwardRef(function TextInput({ options = null, className = '', isFocused = false, ...props }, ref) { const input = ref ? ref : useRef(); useEffect(() => { if (isFocused) { input.current.focus(); } }, []); return ( <select {...props} className={ 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm ' + className } ref={input} > {options.map((option) => ( <option value={option} key={option}>{option}</option> ))} </select> ); }); |