Skip to content

Instantly share code, notes, and snippets.

@dantman
Last active July 21, 2025 00:10
Show Gist options
  • Save dantman/5fb82b6204eb4a365b193c6aa341c257 to your computer and use it in GitHub Desktop.
Save dantman/5fb82b6204eb4a365b193c6aa341c257 to your computer and use it in GitHub Desktop.
Reusable "definedForm" boilerplate

📝 React + Zod + React Hook Form Boilerplate

A reusable pattern for building type-safe, validated forms in React using:

  • react-hook-form: For ergonomic form state management.
  • zod: For schema-based validation and type inference.
  • TypeScript: For full type safety throughout your forms.

Features

  • defineForm(schema): Generates a custom hook for your form, automatically wiring up Zod validation and type inference.
  • DefinedForm: A wrapper component that combines FormProvider and a native <form>, making it easy to use your form context and props.
  • SubmitButton: A submit button that integrates with form state (e.g., disables on submit).

Installation

To install create-form.tsx should be saved to src/components/create-form.tsx (assuming you are using ShadCN's components folder and putting your code in src).

You will need to install: react-hook-form zod @hookform/resolvers use-effect-event

This pattern also assumes you have already installed a components/ui/button.tsx component such as ShadCN UI's.

You will also want to ensure you have CSS like the following inside the @layer base that defines your body and * rules.

@layer base {
	form {
		display: contents;
	}
}

Usage

const useMyForm = defineForm(z.object({
  example: z.string(),
  exampleRequired: z.string().min(1, "This field is required"),
});

function App() {
  const form = useMyForm({
    onSubmit(data) {
      console.log(data);
    },
  });

  console.log(form.watch("example"));

  return (
    <DefinedForm {...{ form }}>
      <input defaultValue="test" {...form.register("example")} />
      <input {...form.register("exampleRequired", { required: true })} />
      {form.formState.errors.exampleRequired && <span>This field is required</span>}
      <button>Submit</button>
    </DefinedForm>
  );
}
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any -- We need to use `any` in form generics */
import { zodResolver } from "@hookform/resolvers/zod";
import {
FieldValues,
FormProvider,
SubmitHandler,
useForm,
UseFormHandleSubmit,
UseFormProps,
UseFormReturn,
useFormState,
} from "react-hook-form";
import { useEffectEvent } from "use-effect-event";
import z from "zod";
import { Button } from "./ui/button";
export type UseDefinedFormParams<
TFieldValues extends FieldValues = FieldValues,
TContext = any,
TTransformedValues = TFieldValues,
> = Omit<
UseFormProps<TFieldValues, TContext, TTransformedValues>,
"resolver"
> & {
onSubmit: SubmitHandler<TTransformedValues>;
};
export type UseDefinedFormReturn<
TFieldValues extends FieldValues = FieldValues,
TContext = any,
TTransformedValues = TFieldValues,
> = UseFormReturn<TFieldValues, TContext, TTransformedValues> & {
onSubmit: ReturnType<UseFormHandleSubmit<TFieldValues, TTransformedValues>>;
};
export type DefinedFormProps<
TFieldValues extends FieldValues = FieldValues,
TContext = any,
TTransformedValues = TFieldValues,
> = {
form: UseDefinedFormReturn<TFieldValues, TContext, TTransformedValues>;
} & Omit<
React.ComponentProps<"form">,
| "form"
| "onSubmit"
| "action"
| "method"
| "acceptCharset"
| "encType"
| "target"
| "rel"
>;
/**
* Define a useForm hook with less boilerplate a Zod schema for validation.
*
* @remarks
* [API](https://react-hook-form.com/docs/useform) • [Demo](https://codesandbox.io/s/react-hook-form-get-started-ts-5ksmm) • [Video](https://www.youtube.com/watch?v=RkXv4AXXC_4)
*
* @param props - form configuration and validation parameters.
*
* @returns methods - individual functions to manage the form state. {@link UseDefinedFormReturn}
*
* @example
* ```tsx
* const useMyForm = defineForm(z.object({
* example: z.string(),
* exampleRequired: z.string().min(1, "This field is required"),
* });
*
* function App() {
* const form = useMyForm({
* onSubmit(data) {
* console.log(data);
* },
* });
*
* console.log(form.watch("example"));
*
* return (
* <DefinedForm {...{ form }}>
* <input defaultValue="test" {...form.register("example")} />
* <input {...form.register("exampleRequired", { required: true })} />
* {form.formState.errors.exampleRequired && <span>This field is required</span>}
* <button>Submit</button>
* </DefinedForm>
* );
* }
* ```
*/
export function defineForm<Schema extends z.ZodObject<any>>(schema: Schema) {
type TFieldValues = z.input<Schema>;
type TTransformedValues = z.output<Schema>;
function useDefinedForm({
onSubmit,
...params
}: UseDefinedFormParams<
TFieldValues,
any,
TTransformedValues
>): UseDefinedFormReturn<TFieldValues, any, TTransformedValues> {
const form = useForm<TFieldValues, any, TTransformedValues>({
...params,
resolver: zodResolver(schema),
});
const submitHandler = useEffectEvent(
form.handleSubmit((values) => onSubmit(values))
);
return { ...form, onSubmit: submitHandler };
}
return useDefinedForm;
}
/**
* A wrapper around react-hook-form's `FormProvider` and a native HTML form element.
* Makes it easy to add a form by passing a `form` prop created from `useDefinedForm`
* and any additional props to the `<form>` element.
*
* @remarks
* [FormProvider API](https://react-hook-form.com/docs/formprovider) • [HTMLFormElement API](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement) • [useForm API](https://react-hook-form.com/docs/useform)
*
* @see {@link defineForm}
* @see {@link DefinedFormProps}
* @see {@link FormProvider}
*/
export function DefinedForm<
TFieldValues extends FieldValues,
TContext = any,
TTransformedValues = TFieldValues,
>({
form: { onSubmit, ...form },
children,
...formProps
}: DefinedFormProps<TFieldValues, TContext, TTransformedValues>) {
return (
<FormProvider {...form}>
<form {...formProps} onSubmit={onSubmit}>
{children}
</form>
</FormProvider>
);
}
/**
* A submit button that integrates with react-hook-form's form state.
* FIXME: You must implement some form of a loading state for your Button
*/
export function SubmitButton({
children,
...props
}: React.ComponentProps<typeof Button>) {
const { isSubmitting } = useFormState();
return (
<Button
type='submit'
{...props}
// isLoading={isSubmitting || props.isLoading}
>
{children}
</Button>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment