
Simplifying Laravel & Inertia React Forms: A Custom Wrapper
Ali Alizadeh
Sun Mar 16 2025
7 min
Table of Contents
- Why ?
- Solution
- The UI Library
- Label Component
- Error Message Component
- Field Wrapper Component
- Textarea and Input Component
- Button Component
- Common Fields Fixtures
- Putting it all together
- Limitations
Why ?
Inertia.js provides a robust foundation for form handling, but we wanted to further simplify and optimize this process for our team. This article outlines our approach, sharing reusable React components designed to enhance form management within Inertia.js applications.
Note: If you prefer using React libraries like react-hook-form
, this approach might not align with your needs.
Solution
We developed a set of custom React components and a wrapper to streamline form creation. Here's a breakdown of our implementation.
The UI Library
We leverage Tailwindcss combined with DaisyUI for our component styling, customizing them to meet specific project requirements.
Utility Functions
To manage class names effectively, we utilize a utility function cn
, which merges class names.
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...values: ClassValue[]) {
return twMerge(clsx(...values));
}
Understanding tailwind-merge
tailwind-merge resolves conflicting Tailwind CSS class names, ensuring that the intended styles are applied.
Here's an example from their docs for more information you can read tailwind-merge Github
function MyGenericInput(props) {
const className = `border rounded px-2 py-1 ${props.className || ''}`
return <input {...props} className={className} />
}
function MyOneOffInput(props) {
return (
<MyGenericInput
{...props}
className="py-3" // ← Only want to change some padding
/>
)
}
tailwind-merge prevents potential style conflicts between py-1
and py-3
by only keep the last one.
Understanding clsx
clsx conditionally joins class names, simplifying the management of dynamic class attributes.
Here's the example from their docs for more information you can read clsx Github
import clsx from 'clsx';
clsx('foo', true && 'bar', 'baz'); // 'foo bar baz'
clsx({ foo: true, bar: false, baz: true }); // 'foo baz'
Form Component
Our base Form component provides a context for managing form state and submission.
import { THasChildren } from '@/types';
import { cn } from '@/utils';
import { useForm } from '@inertiajs/react';
import React, { createContext } from 'react';
export type TInertiaForm<TForm extends Record<string, any>> = ReturnType<typeof useForm<TForm>>;
export const formContext = createContext<TInertiaForm<any>>({} as any);
type FormProps<TForm extends object> = THasChildren &
React.FormHTMLAttributes<HTMLFormElement> & {
onSubmit: () => void;
form: TInertiaForm<TForm>;
styleMode: 'grid' | 'base' | 'none';
};
export default function Form<TForm extends Record<string, unknown>>({
children,
onSubmit,
className,
form,
styleMode,
...props
}: FormProps<TForm>) {
return (
<form
{...props}
className={cn(
{
'space-y-4': styleMode === 'base',
'grid grid-cols-1 gap-4 md:grid-cols-2': styleMode === 'grid',
},
className,
)}
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<formContext.Provider value={form}>{children}</formContext.Provider>
</form>
);
}
Label Component
The Label component enhances form field labeling with optional information tooltips and required field indicators.
import { cn } from '@/utils';
import { HiOutlineInformationCircle } from 'react-icons/hi2';
type LabelProps = {
label: string;
htmlFor: string;
isRequired?: boolean;
info?: string;
className?: string;
};
export default function Label({ label, htmlFor, info, className, isRequired = false }: LabelProps) {
return (
<label htmlFor={htmlFor} className={cn('label w-full font-medium', className)}>
{label} {isRequired && <span className="text-error">*</span>}{' '}
{info && (
<button data-tip={info} className="group tooltip isolate z-[1]">
<HiOutlineInformationCircle strokeWidth={2} className="inline-block size-5" />
</button>
)}
</label>
);
}
Error Message Component
The ErrorMessage component displays validation errors associated with form fields.
import { useContext } from 'react';
import { formContext } from './Form';
type ErrorMessageProps = {
name: string;
};
export default function ErrorMessage({ name }: ErrorMessageProps) {
const { errors } = useContext(formContext);
const error = errors[name];
if (!error) return null;
return <p className="mt-1 text-sm text-error">{error}</p>;
}
Field Wrapper Component
The FieldWrapper component encapsulates form field logic, including labels, error messages, and field rendering.
import { cn } from '@/utils';
import React, { useContext } from 'react';
import ErrorMessage from './ErrorMessage';
import { formContext } from './Form';
import Label from './Label';
export type FieldProps = {
name: string;
isRequired: boolean;
label: { text: string; bottom?: { left?: string; right?: string }; className?: string };
children:
| React.ReactNode
| ((props: {
hasError: boolean;
name: string;
id: string;
className?: string;
}) => React.ReactNode);
info?: string;
className?: string;
attributes?: object;
inputClassName?: string;
};
export default function FieldWrapper({
name,
label,
info,
isRequired,
className,
children,
}: FieldProps) {
const { errors } = useContext(formContext);
const hasError = Boolean(errors[name]);
return (
<div className={cn('relative w-full space-y-2', className)}>
<Label
className={label.className}
info={info}
isRequired={isRequired}
htmlFor={name}
label={label.text}
/>
{typeof children === 'function' ? children({ hasError, name, id: name }) : children}
{label.bottom && (
<div className="label font-medium">
{label.bottom.left && <span className="label-text-alt">{label.bottom.left}</span>}
{label.bottom.right && <span className="label-text-alt">{label.bottom.right}</span>}
</div>
)}
<ErrorMessage name={name} />
</div>
);
}
Textarea and Input Component
These components provide standardized input fields with built-in error handling and data binding.
import { cn } from '@/utils';
import get from 'lodash/get';
import { TextareaHTMLAttributes, useContext } from 'react';
import FieldWrapper, { FieldProps } from './FieldWrapper';
import { formContext } from './Form';
type TextareaProps = Omit<FieldProps, 'children'> & {
attributes?: TextareaHTMLAttributes<HTMLTextAreaElement>;
textareaClassName?: string;
};
export default function Textarea({ attributes, textareaClassName, ...props }: TextareaProps) {
const { clearErrors, setData, data } = useContext(formContext);
const value = get(data, props.name);
return (
<FieldWrapper {...props}>
{({ hasError, ...fieldProps }) => (
<textarea
{...fieldProps}
{...attributes}
value={value as string}
onChange={(e) => {
if (hasError) clearErrors(props.name);
setData(props.name, e.target.value);
}}
className={cn('textarea w-full', textareaClassName, {
'textarea-error': hasError,
})}
/>
)}
</FieldWrapper>
);
}
import { cn } from '@/utils';
import get from 'lodash/get';
import { InputHTMLAttributes, useContext } from 'react';
import FieldWrapper, { FieldProps } from './FieldWrapper';
import { formContext } from './Form';
type InputProps = Omit<FieldProps, 'children'> & {
attributes?: InputHTMLAttributes<HTMLInputElement>;
inputClassName?: string;
};
export default function Input({ attributes, inputClassName, ...props }: InputProps) {
const { clearErrors, setData, data } = useContext(formContext);
const value = get(data, props.name);
return (
<FieldWrapper {...props}>
{({ hasError, ...fieldProps }) => (
<input
{...fieldProps}
{...attributes}
value={value as string}
onChange={(e) => {
if (hasError) clearErrors(props.name);
setData(props.name, e.target.value);
}}
className={cn('input w-full', inputClassName, {
'input-error': hasError,
})}
/>
)}
</FieldWrapper>
);
}
Button Component
The Button component includes an isLoading prop to indicate processing states.
import { cn } from '@/utils';
import React from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
}
export default function Button({ children, isLoading, className, ...props }: ButtonProps) {
return (
<button
type="button"
{...props}
className={cn(className, {
'cursor-not-allowed': props.disabled && !isLoading,
'cursor-wait': isLoading,
})}
>
{isLoading ? <span className="loading loading-spinner" /> : children}
</button>
);
}
Common Fields Fixtures
We maintain a set of common field configurations for reusability.
import { FieldProps } from '@/shared/forms/FieldWrapper';
const fields = {
email: {
isRequired: true,
name: 'email',
label: { text: 'Email' },
attributes: { type: 'email', autoComplete: 'username' },
},
password: {
isRequired: true,
name: 'password',
label: { text: 'Password' },
attributes: { type: 'password', autoComplete: 'current-password' },
},
passwordConfirmation: {
isRequired: true,
name: 'password_confirmation',
label: { text: 'Password Confirmation' },
attributes: { type: 'password', autoComplete: 'current-password' },
},
fullname: {
isRequired: true,
name: 'fullname',
label: { text: 'Full name' },
attributes: { type: 'text' },
},
title: {
isRequired: true,
name: 'title',
label: { text: 'Title' },
attributes: { type: 'text' },
},
description: {
isRequired: true,
name: 'description',
label: { text: 'Description' },
className: 'col-span-full',
},
} as const satisfies Record<string, Omit<FieldProps, 'children'>>;
export default fields;
Putting it all together
export default function FormContactUs() {
const form = useForm({ fullname: '', email: '', title: '', description: '' });
const handleSubmit = () => {
form.post(route('contact-us'), {
onSuccess: () => {
form.reset();
},
});
};
return (
<Form form={form} onSubmit={handleSubmit} styleMode="grid">
{form.wasSuccessful && (
<div role="alert" className="alert alert-success col-span-full">
<HiOutlineCheckCircle className="size-6" />
<span>Your message has been sent successfully</span>
</div>
)}
<Input {...fields.fullname} />
<Input {...fields.email} />
<Input {...fields.title} className="col-span-full" />
<Textarea {...fields.description} />
<div className="col-span-full w-full">
<Button
type="submit"
className="btn btn-primary btn-block md:w-auto"
disabled={form.processing}
isLoading={form.processing}
>
Submit
</Button>
</div>
</Form>
);
}
Now bam, we have a working form! 🎉
Limitations
While our custom form components offer a streamlined approach to Inertia.js form handling, it's important to acknowledge certain limitations:
Dependency on the Form Component
All field components (Input, Textarea, etc.) must be nested within the Form component. This dependency arises from the use of formContext to manage form state and errors.
Potential Performance Implications
Due to the context-based approach, every field change triggers a re-render of the entire form.
For applications with extremely large or complex forms, this could potentially lead to performance bottlenecks.
For scenarios involving numerous forms or performance-critical applications, alternative solutions such as integrating react-hook-form
with a custom data management strategy might be more suitable.
More Blogs

Effortless Laravel & Inertia Data and Type Sync: DTO Magic
Stop manual data mapping! Discover how DTOs streamline type and data synchronization between Laravel and Inertia for faster development.