Monday, April 21, 2025
Form Handling with Laravel-React Starter Kit Using Inertia.js
Posted by
Form Handling with Laravel-React Starter Kit Using Inertia.js
In this tutorial, we'll explore how to handle forms in a Laravel application that uses React through Inertia.js. We'll cover everything from basic form creation to image uploads, routing, flash messages, and data fetching.
Introduction
Inertia.js bridges the gap between your Laravel backend and React frontend, allowing you to build single-page applications without the complexity of setting up a separate API. This approach gives you the best of both worlds: Laravel's robust backend capabilities and React's dynamic frontend interactivity.
Prerequisites
- Laravel installed
- Inertia.js set up with React
- Basic understanding of Laravel and React
- Shadcn UI components installed
Routing in Laravel with Inertia.js
In a Laravel application using Inertia.js, routing works slightly differently from traditional Laravel applications. Let's look at how routes are defined:
// routes/web.php
use App\Http\Controllers\Auth\RegisteredUserController;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
// Other authentication routes...
});
In this example, when a user visits /register
, Laravel calls the create
method on the RegisteredUserController
, which returns an Inertia response that renders a React component:
// App\Http\Controllers\Auth\RegisteredUserController.php
public function create(): Response
{
return Inertia::render('auth/register');
}
The Inertia::render()
function tells Inertia to render the register
component located in your React components directory.
Form Handling with Inertia.js and React
Basic Form Setup
Let's examine how a registration form is set up using React and Inertia.js:
// resources/js/Pages/auth/register.jsx
import { Head, useForm } from "@inertiajs/react";
import { LoaderCircle } from "lucide-react";
import { FormEventHandler } from "react";
import InputError from "@/components/input-error";
import TextLink from "@/components/text-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AuthLayout from "@/layouts/auth-layout";
type RegisterForm = {
name: string,
email: string,
password: string,
password_confirmation: string,
};
export default function Register() {
const { data, setData, post, processing, errors, reset } =
useForm <
Required <
RegisterForm >>
{
name: "",
email: "",
password: "",
password_confirmation: "",
};
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route("register"), {
onFinish: () => reset("password", "password_confirmation"),
});
};
return (
<AuthLayout
title="Create an account"
description="Enter your details below to create your account"
>
<Head title="Register" />
<form className="flex flex-col gap-6" onSubmit={submit}>
{/* Form fields here */}
</form>
</AuthLayout>
);
}
Key Components in Form Handling
-
The
useForm
Hook: This is provided by Inertia.js to handle form state, submission, and errors. -
Form Data: We initialize our form data with empty values.
-
Form Submission: The
post
function fromuseForm
handles form submission to the route specified (route('register')
). -
Error Handling: Inertia automatically populates the
errors
object with validation errors from Laravel. -
Processing State: The
processing
state indicates when the form is being submitted, allowing us to disable inputs and show loading indicators.
Creating a Product Form with Image Upload
Now, let's create a more complex form for creating a product with image upload. This example will show how to:
- Upload and display images
- Select categories with search functionality using Shadcn UI components
Controller Setup
First, let's look at the controller for handling product creation:
// App\Http\Controllers\ProductController.php
namespace App\Http\Controllers;
use App\Models\Product;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProductController extends Controller
{
public function create()
{
$categories = Category::all();
return Inertia::render('Products/Create', [
'categories' => $categories
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'price' => 'required|numeric',
'category_id' => 'required|exists:categories,id',
'image' => 'required|image|max:2048',
]);
$imagePath = null;
if ($request->hasFile('image')) {
$imagePath = $request->file('image')->store('products', 'public');
}
$product = Product::create([
'user_id' => auth()->user()->id,
'title' => $validated['title'],
'description' => $validated['description'],
'price' => $validated['price'],
'category_id' => $validated['category_id'],
'image' => $imagePath,
]);
return to_route('products.index')
->with('message', 'Product created successfully.');
}
}
React Component for Product Creation
Now, let's build the React component for creating a product with image upload:
// resources/js/Pages/Products/Create.jsx
import { Head, useForm } from "@inertiajs/react";
import { useState } from "react";
import { LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import InputError from "@/components/input-error";
import MainLayout from "@/layouts/main-layout";
export default function CreateProduct({ categories }) {
const { data, setData, post, processing, errors } = useForm({
title: "",
description: "",
price: "",
category_id: "",
image: null,
});
const [preview, setPreview] = useState(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
setData("image", file);
// Create a preview URL
setPreview(URL.createObjectURL(file));
}
};
const submit = (e) => {
e.preventDefault();
post(route("products.store"));
};
return (
<MainLayout>
<Head title="Create Product" />
<div className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Create New Product</h1>
<form onSubmit={submit} className="max-w-2xl space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData("title", e.target.value)}
placeholder="Product title"
/>
<InputError message={errors.title} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData("description", e.target.value)}
placeholder="Product description"
rows={5}
/>
<InputError message={errors.description} />
</div>
<div className="space-y-2">
<Label htmlFor="price">Price</Label>
<Input
id="price"
type="number"
step="0.01"
value={data.price}
onChange={(e) => setData("price", e.target.value)}
placeholder="0.00"
/>
<InputError message={errors.price} />
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.category_id} />
</div>
<div className="space-y-2">
<Label htmlFor="image">Product Image</Label>
<div className="mt-1">
<Input
id="image"
type="file"
onChange={handleImageChange}
className="mt-1"
accept="image/*"
/>
<InputError message={errors.image} />
</div>
{preview && (
<div className="mt-2">
<img
src={preview}
alt="Preview"
className="mt-2 rounded-lg object-cover h-32"
/>
</div>
)}
</div>
<Button type="submit" className="w-full" disabled={processing}>
{processing && (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
)}
Create Product
</Button>
</form>
</div>
</MainLayout>
);
}
Setting Up Storage for Image Uploads
Laravel provides a convenient way to handle file uploads through its filesystem configuration. To make uploaded files accessible from the web, you need to create a symbolic link between the public
directory and the storage
directory.
Run the following command:
php artisan storage:link
This creates a symbolic link from public/storage
to storage/app/public
, making files in the storage/app/public
directory accessible via the web at http://your-app.test/storage
.
Flash Messages
Flash messages are temporary notifications that appear after an action is performed. Let's look at how to display flash messages in an Inertia.js application:
Controller Side
When redirecting after a successful form submission, you can attach a flash message:
return to_route('products.index')
->with('message', 'Product created successfully.');
React Component Side
In your layout component, you can access flash messages from the page props:
// resources/js/layouts/main-layout.jsx
import { usePage } from "@inertiajs/react";
import { useEffect } from "react";
import { toast } from "@/components/ui/toast";
export default function Dashboard({ children }) {
const { flash } = usePage().props;
useEffect(() => {
if (flash.message) {
toast.success(flash.message);
}
}, [flash.message]);
return (
<div className="min-h-screen bg-gray-100">
{/* Your layout content */}
<main>{children}</main>
</div>
);
}
Fetching and Sending Data
Fetching Data for Pages
In Inertia.js, data is passed to React components through the controller:
// Controller
public function index()
{
$posts = Post::with('user')->latest()->get();
return Inertia::render('Posts/Index', [
'posts' => $posts
]);
}
Accessing Data in React Components
In your React component, you can access this data as props:
// resources/js/Pages/Posts/Index.jsx
export default function Index({ posts }) {
return (
<div>
<h1>Posts</h1>
<div className="mt-6">
{posts.map((post, index) => (
<div key={post.id} className="mb-4">
<h2>{post.title}</h2>
<p>{post.content.substring(0, 50)}...</p>
</div>
))}
</div>
</div>
);
}
Sending Data Back to the Server
Using the useForm
hook, you can easily send data back to the server:
const { data, setData, post, processing } = useForm({
title: "",
content: "",
});
function submit(e) {
e.preventDefault();
post(route("posts.store"));
}
Building a Post Management System - Complete Example
Let's put everything together and build a post management system that includes listing, creating, and showing posts with image uploads.
Routes
// routes/web.php
Route::middleware(['auth'])->group(function () {
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
});
Controller
// App\Http\Controllers\PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['user', 'category'])
->latest()
->get();
return Inertia::render('Posts/Index', [
'posts' => $posts
]);
}
public function create()
{
$categories = Category::all();
return Inertia::render('Posts/Create', [
'categories' => $categories
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'required|string',
'category' => 'required|string',
'image' => 'required|image|max:2048'
]);
$file = $request->file('image');
$filePath = $file->store('posts', 'public');
Post::create([
'user_id' => auth()->user()->id,
'title' => $request->title,
'slug' => \Str::slug($request->title),
'content' => $request->content,
'status' => $request->status,
'category' => $request->category,
'image' => $filePath
]);
return to_route('posts.index')
->with('message', 'Post created successfully.');
}
public function show(Post $post)
{
return Inertia::render('Posts/Show', [
'post' => $post->load(['user', 'category'])
]);
}
}
Model
// App\Models\Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'title', 'slug', 'content',
'status', 'category', 'image'
];
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
}
React Components
Posts Index Component
// resources/js/Pages/Posts/Index.jsx
import { Head, Link } from "@inertiajs/react";
import MainLayout from "@/layouts/main-layout";
export default function Index({ posts }) {
return (
<MainLayout>
<Head title="Posts" />
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Posts</h1>
<Link
href={route("posts.create")}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md"
>
Create Post
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full bg-white shadow-md rounded-lg overflow-hidden">
<thead className="bg-gray-100">
<tr>
<th className="px-6 py-3 text-left">#</th>
<th className="px-6 py-3 text-left">Image</th>
<th className="px-6 py-3 text-left">Title</th>
<th className="px-6 py-3 text-left">Content</th>
<th className="px-6 py-3 text-left">Category</th>
<th className="px-6 py-3 text-left">Status</th>
</tr>
</thead>
<tbody>
{posts.map((post, index) => (
<tr key={post.id} className="border-t hover:bg-gray-50">
<td className="px-6 py-4">{index + 1}</td>
<td className="px-6 py-4">
<img
src={`/storage/${post.image}`}
alt={post.title}
className="w-16 rounded"
/>
</td>
<td className="px-6 py-4">{post.title}</td>
<td className="px-6 py-4">
{post.content.substring(0, 50)}...
</td>
<td className="px-6 py-4">{post.category}</td>
<td className="px-6 py-4">{post.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</MainLayout>
);
}
Posts Create Component
// resources/js/Pages/Posts/Create.jsx
import { Head, useForm } from "@inertiajs/react";
import { useState } from "react";
import { LoaderCircle } from "lucide-react";
import MainLayout from "@/layouts/main-layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import InputError from "@/components/input-error";
export default function Create({ categories }) {
const { data, setData, post, processing, errors } = useForm({
title: "",
content: "",
status: "draft",
category: "",
image: null,
});
const [preview, setPreview] = useState(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
setData("image", file);
setPreview(URL.createObjectURL(file));
}
};
const submit = (e) => {
e.preventDefault();
post(route("posts.store"));
};
return (
<MainLayout>
<Head title="Create Post" />
<div className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Create New Post</h1>
<form onSubmit={submit} className="max-w-2xl space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData("title", e.target.value)}
placeholder="Post title"
/>
<InputError message={errors.title} />
</div>
<div className="space-y-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
value={data.content}
onChange={(e) => setData("content", e.target.value)}
placeholder="Post content"
rows={6}
/>
<InputError message={errors.content} />
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={data.status}
onValueChange={(value) => setData("status", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
value={data.category}
onValueChange={(value) => setData("category", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.name}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.category} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">Post Image</Label>
<div className="mt-1">
<Input
id="image"
type="file"
onChange={handleImageChange}
className="mt-1"
accept="image/*"
/>
<InputError message={errors.image} />
</div>
{preview && (
<div className="mt-2">
<img
src={preview}
alt="Preview"
className="mt-2 rounded-lg object-cover h-48"
/>
</div>
)}
</div>
<Button type="submit" className="w-full" disabled={processing}>
{processing && (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
)}
Create Post
</Button>
</form>
</div>
</MainLayout>
);
}
Conclusion
In this tutorial, we've covered the essentials of form handling with Laravel, React, and Inertia.js:
-
Basic Form Setup: We learned how to use the
useForm
hook to manage form state and submission. -
Image Uploads: We implemented image uploads using Laravel's file storage system with the
php artisan storage:link
command. -
Routing: We saw how routing works with Inertia.js, maintaining Laravel's routing system while rendering React components.
-
Flash Messages: We learned how to pass flash messages from controllers and display them in React components.
-
Fetching and Sending Data: We covered how to pass data from controllers to React components and send data back to the server.
By combining Laravel's powerful backend capabilities with React's dynamic frontend through Inertia.js, you can build modern, reactive applications without the complexity of setting up a separate API. This approach streamlines development while maintaining the full power of both frameworks.
The Laravel-React-Inertia.js stack provides an excellent developer experience and allows you to build sophisticated applications with clean, maintainable code. Whether you're building a simple form or a complex application, this stack gives you the tools you need to succeed.