Simplifying Laravel & Inertia React Forms: A Custom Wrapper

Simplifying Laravel & Inertia React Forms: A Custom Wrapper

Laravel LaravelReact React
A

Ali Alizadeh

Sun Mar 16 2025

7 min

Table of Contents

  1. Why ?
  2. Solution
  3. The UI Library
  4. Label Component
  5. Error Message Component
  6. Field Wrapper Component
  7. Textarea and Input Component
  8. Button Component
  9. Common Fields Fixtures
  10. Putting it all together
  11. 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

Shoes

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.

Laravel Laravel