Back to blog

Monday, April 21, 2025

Form Handling with Laravel-React Starter Kit Using Inertia.js

cover

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

  1. The useForm Hook: This is provided by Inertia.js to handle form state, submission, and errors.

  2. Form Data: We initialize our form data with empty values.

  3. Form Submission: The post function from useForm handles form submission to the route specified (route('register')).

  4. Error Handling: Inertia automatically populates the errors object with validation errors from Laravel.

  5. 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:

  1. Upload and display images
  2. 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:

  1. Basic Form Setup: We learned how to use the useForm hook to manage form state and submission.

  2. Image Uploads: We implemented image uploads using Laravel's file storage system with the php artisan storage:link command.

  3. Routing: We saw how routing works with Inertia.js, maintaining Laravel's routing system while rendering React components.

  4. Flash Messages: We learned how to pass flash messages from controllers and display them in React components.

  5. 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.