The Ultimate InertiaJS Form Abstraction

Typesafe forms in React with InertiaJS? I got you covered. The `useForm` hook from InertiaJS is a very basic implementation of a form abstraction. It's not very flexible and requires a lot of boilerplate. In this article I'll show you how to create a form abstraction that is flexible, typesafe and requires almost no boilerplate.

Introduction

I first want to mention that this abstraction is based on the abstraction created by Brendonovich from his post: The Ultimate Form Abstraction

The InertiaJS form helper requires you to manually add the value and onChange props to all your inputs. It also requires you to manually add the errors to your inputs. This is how you would create a login form with the InertiaJS form helper:

// LoginForm.tsx
import { useForm } from "@inertiajs/react";

function LoginForm() {
  const { data, setData, post, processing, errors } = useForm({
    email: "",
    password: "",
  });

  function submit(e) {
    e.preventDefault();
    post("/login");
  }

  return (
    <form onSubmit={submit}>
      <input
        name="email"
        type="text"
        value={data.email}
        onChange={(e) => setData("email", e.target.value)}
      />
      {errors.email && <div>{errors.email}</div>}
      <input
        name="password"
        type="password"
        value={data.password}
        onChange={(e) => setData("password", e.target.value)}
      />
      {errors.password && <div>{errors.password}</div>}

      <button type="submit" disabled={processing}>
        Login
      </button>
    </form>
  );
}

This is works, but is A LOT of boilerplate and can be error prone. The abstraction we are going to create will remove this boilerplate and still keep it typesafe.

Our implementation

Lets start by creating our own useForm hook in a new file called Form.tsx:

// Form.tsx

import { useForm as useInertiaForm } from "@inertiajs/react";
import { type ChangeEvent } from "react";

type FormSchema = Record<string, unknown>;
type InputElements = HTMLInputElement | HTMLSelectElement;

export function useForm<Data extends FormSchema>(
  id: string,
  data: Data = {} as Data
) {
  const form = useInertiaForm<Data>(id, data);

  function register(name: keyof Data) {
    return {
      name,
      value: form.data[name],
      onChange: (e: ChangeEvent<InputElements>) => {
        form.setData(
          name,
          e.currentTarget.value as (typeof form.data)[typeof name]
        );
      },
    } as const;
  }

  return { ...form, id, register } as const;
}

Our hook exposes a custom register function based on the function of a popular form library: React Hook Form that will return the name, value and onChange props for the input. This will allow us to remove the boilerplate from our inputs. Lets see how we can use this hook in our login form:

// LoginForm.tsx
import { useForm } from "./Form";

function LoginForm() {
  const { post, processing, errors, register } = useForm("login", {
    email: "",
    password: "",
  });

  function submit(e) {
    e.preventDefault();
    post("/login");
  }

  return (
    <form onSubmit={submit}>
      <input type="text" {...register("email")} />
      {errors.email && <div>{errors.email}</div>}

      <input type="password" {...register("password")} />
      {errors.password && <div>{errors.password}</div>}

      <button type="submit" disabled={processing}>
        Login
      </button>
    </form>
  );
}

As you can see, we removed the name, value and onChange props from our inputs and replaced them with the register function. This is already a big improvement, but we can do even better. Let's create a custom Form component that will handle the onSubmit and disabled props and exposes the form context to all its children:

// Form.tsx
import { type VisitOptions } from "@inertiajs/core";
import {
  createContext,
  useContext,
  type ComponentPropsWithoutRef,
  type HTMLAttributes,
} from "react";

type UseFormReturn<T extends FormSchema> = ReturnType<typeof useForm<T>>;
type BaseFormProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit">;

const FormContext = createContext<UseFormReturn<FormSchema> | null>(null);

interface FormProps<T extends FormSchema> extends BaseFormProps {
  form: UseFormReturn<T>;
  method: "post" | "put" | "patch" | "delete" | "get";
  action: string;
  options?: VisitOptions;
}

export function Form<T extends FormSchema>({
  form,
  children,
  options = {},
  ...props
}: FormProps<T>) {
  return (
    // @ts-expect-error - No generic support for createContext
    <FormContext.Provider value={form}>
      <form
        id={form.id}
        noValidate={true}
        onSubmit={(evt) => {
          evt.preventDefault();

          form.submit(props.method, props.action, {
            ...options,
            errorBag: options.errorBag || form.id,
          });
        }}
        {...props}
      >
        {/* disable the input fields while submitting */}
        <fieldset disabled={form.processing}>{children}</fieldset>
      </form>
    </FormContext.Provider>
  );
}

export const useFormContext = () => useContext(FormContext);

This is a lot of code at once so lets break it down.

We did two things: we have created an abstraction for the Form component and we have created a FormContext that will expose the form context to all its children.

This will allow us to remove the onSubmit prop from our form and the children of our form have access to the form context.

Lets see how we can use this component in our login form:

// LoginForm.tsx
import { useForm, Form } from "./Form";

function LoginForm() {
  const form = useForm("login", {
    email: "",
    password: "",
  });

  return (
    <Form form={form} method="post" action="/login">
      <input type="text" {...form.register("email")} />
      {errors.email && <div>{errors.email}</div>}

      <input type="password" {...form.register("password")} />
      {errors.password && <div>{errors.password}</div>}

      <button type="submit" disabled={processing}>
        Login
      </button>
    </Form>
  );
}

It's already looking a lot better. We have removed the onSubmit prop from our form and we have access to the form context in our inputs. Lets create a custom Input and SubmitButton component that will handle the remaining state we have in our form

// Input.tsx
import { ComponentProps, forwardRef } from "react";
import { useFormContext } from "./Form";

interface Props extends ComponentProps<"input"> {
  name: string;
}

const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
  const form = useFormContext();
  const error = form?.errors[props.name];

  return (
    <div>
      <input {...props} ref={ref} />
      {!!error && <p>{error}</p>}
    </div>
  );
});

export default Input;

The component’s Props type is almost identical to a regular input element’s props, except we make name required as it is needed to fetch the field’s errors. The value for name is passed through by register().

// SubmitButton.tsx
import { ComponentProps } from "react";
import { useFormContext } from "./Form";

interface Props extends ComponentProps<"button"> {}

export function SubmitButton({ children, ...props }: Props) {
  const ctx = useFormContext();
  const loading = ctx?.processing || false;

  return (
    <button type="submit" disabled={loading} {...props}>
      {children}
    </button>
  );
}

The submit button is a bit simpler. We only need to disable the button when the form is processing.

The final result

Here’s a final example demonstrating everything that has been discussed: a custom Form component, a custom Input component and a custom SubmitButton component. As you can see, we have removed all the boilerplate and we have a nice abstraction for our form while keeping it typesafe.

// LoginForm.tsx
import { useForm, Form } from "./Form";
import { Input } from "./Input";
import { SubmitButton } from "./SubmitButton";

function LoginForm() {
  const form = useForm("login", {
    email: "",
    password: "",
  });

  return (
    <Form form={form} method="post" action="/login">
      <Input type="text" {...form.register("email")} />
      <Input type="password" {...form.register("password")} />

      <SubmitButton>Login</SubmitButton>
    </Form>
  );
}

Thanks for reading this post! If you have any questions or feedback, feel free to reach out to me on Twitter.

© 2024 Ruben Jansen. All rights reserved.