Skip to content

Repository files navigation

use-form-definition

A UI-agnostic React form library. You write one field definition; it generates the Zod schema, manages React Hook Form state, renders the form, and exposes the same definition for server-side validation.

When this saves you time

If you already reach for React Hook Form + Zod on most forms and end up repeating the same field metadata across the schema, the RHF setup, the JSX, and a server-side validator, this library bundles those four into one place.

If you only need one or two of those (for example, a single small form where writing a Zod schema by hand isn't a chore), the abstraction may not pay for itself. RHF or Zod on their own is usually enough in that case.

What it does

  • One field definition drives the schema, form state, rendering, and server-side validation
  • Works with any UI library (shadcn, MUI, Ant Design, your own components)
  • Types are inferred from the definition; no manual sync between schema and form
  • Same definition validates client-side and server-side (Next.js server actions, API routes)
  • Built-in support for nested/repeater fields, requiredWhen conditional rules, translation, and an async-validation plugin point
  • Reference components can be copied into your project via a CLI if you'd rather own them than import them

Installation

npm install use-form-definition react react-hook-form zod

Requirements

  • React >= 19.0.0
  • React Hook Form >= 7.55.0
  • Zod >= 3.0.0 < 4.0.0

Quick start

1. Configure your form hook

// lib/form.ts
import { createFormDefinitionHook } from 'use-form-definition';
import { Input, Select, Field } from '@/components/form';

export const useFormDefinition = createFormDefinitionHook({
  components: {
    text: Input,
    email: Input,
    select: Select,
  },
  formComponents: {
    Field: Field,
  },
});

2. Define your form

// forms/user.ts
import { FormDefinition } from 'use-form-definition';

export const userFormDefinition: FormDefinition = {
  name: {
    type: 'text',
    label: 'Name',
    validation: { required: true, minLength: 2 },
  },
  email: {
    type: 'email',
    label: 'Email',
    validation: { required: true },
  },
  role: {
    type: 'select',
    label: 'Role',
    options: [
      { value: 'admin', label: 'Admin' },
      { value: 'user', label: 'User' },
    ],
  },
};

3. Render your form

// components/UserForm.tsx
import { useFormDefinition } from '@/lib/form';
import { userFormDefinition } from '@/forms/user';

export function UserForm() {
  const { RenderedForm } = useFormDefinition(userFormDefinition);

  return <RenderedForm onSubmit={(data) => console.log(data)} />;
}

Conditional visibility

When a field's visibility depends on the form's current state (e.g. show "Please specify" only when "Other" is selected), drop down from <RenderedForm /> to manual rendering with <Form> and <RenderedField>, and use form.watch():

const { form, Form, RenderedField, Actions } = useFormDefinition(contactDefinition);
const subject = form.watch('subject');

return (
  <Form onSubmit={form.handleSubmit(onSubmit)}>
    <RenderedField name="name" />
    <RenderedField name="email" />
    <RenderedField name="subject" />
    {subject === 'other' && <RenderedField name="customSubject" />}
    <RenderedField name="message" />
    <Actions />
  </Form>
);

The same pattern applies to per-field runtime props like disabled or options: pass them as props on <RenderedField> and the prop wins over the definition default. See examples/basic-react/src/pages/ContactPage.tsx for a working example.

One difference from RenderedForm: <Form> is a plain passthrough and does not default method="post", so a submit that lands before React hydrates falls back to the browser's native GET and serializes the fields into the URL. On any form that handles credentials, pass method="post" yourself - see Why client-only forms POST.

<Form> submits client-side only. If the form has a serverAction and you still want a custom layout (conditional fields, multi-column sections, an image beside the inputs), pass children to <RenderedForm> instead: your body renders in place of the automatic field grid, but RenderedForm keeps the whole progressive-enhancement path - <form action>, no-JS submission, and server errors on the fields. Compose it from the hook's RenderedField and Actions:

const { form, RenderedForm, RenderedField, Actions } = useFormDefinition(definition, { serverAction });
const subject = form.watch('subject');

return (
  <RenderedForm>
    <RenderedField name="name" />
    {subject === 'other' && <RenderedField name="customSubject" />}
    <RenderedField name="message" />
    <Actions />
  </RenderedForm>
);

See Custom layout for details.

To type custom props your field components accept (anything beyond the standard overrides), pass a second type argument to the hook: useFormDefinition<typeof definition, { tooltip?: string }>(definition). Those props are then checked on <RenderedField>, so a typo or wrong value type is a compile error. Without it they stay permissively typed; either way they're filtered at runtime against each field type's allowlist.

Derived fields

A field can auto-fill from a sibling field while the user hasn't claimed it - a slug that follows the title until the user edits it by hand:

const definition = {
  title: { type: 'text', validation: { required: true } },
  slug: { type: 'slug', deriveFrom: 'title', validation: { required: true, pattern: 'slug' } },
};

While the slug is empty (or still holds the last derived value), typing in the title mirrors a transformed copy into it; a stored slug on an edit form is never touched, a hand-edited slug stops derivation, and clearing the slug re-arms it. Derived writes don't mark the field dirty.

The transform comes from the field kind - register it once with registerFieldType('slug', { deriveTransform: slugify }) - or per field via a deriveTransform function on the definition. With neither, the value is mirrored verbatim. Derivation is client-side only: server validation ignores deriveFrom, so the definition stays shareable with server code. See Derived fields for details.

Copy and customize components

Use the CLI to copy reference components to your project:

# Copy all basic components
npx use-form-definition copy all ./src/components/form/

# Copy individual components
npx use-form-definition copy text-input ./src/components/
npx use-form-definition copy field ./src/components/

Documentation

Examples

See the examples directory for complete implementations:

Example Description
basic-react Core features with built-in unstyled components
nextjs Server actions, i18n, API routes
mui Material UI integration
antd Ant Design integration
shadcn shadcn/ui + Tailwind CSS

Server actions (Next.js)

Pass a server action to useFormDefinition and the form works with or without JavaScript: with JS it intercepts on submit, runs client-side validation, and dispatches the action; without JS the <form> posts natively to the server action, and the server's field errors render server-side. Render the result view from the returned actionState.

// app/users/actions.ts
'use server';
import { generateDataValidator, parseValidationErrors } from 'use-form-definition/server';
import { userFormDefinition } from './definition';

export async function createUser(prevState: unknown, formData: FormData) {
  const result = generateDataValidator(userFormDefinition)(formData);

  if (!result.success) {
    return {
      success: false as const,
      errors: parseValidationErrors(result.error.issues),
      values: Object.fromEntries(formData.entries()), // so fields repopulate without JS
    };
  }

  // ...persist result.data...
  return { success: true as const, data: result.data };
}
// app/users/new-user-form.tsx
'use client';
import { useFormDefinition } from '@/lib/form';
import { userFormDefinition } from './definition';
import { createUser } from './actions';

export function NewUserForm() {
  const { RenderedForm, actionState, isPending } = useFormDefinition(userFormDefinition, {
    serverAction: createUser,
  });

  if (actionState?.success) return <p>Created {String(actionState.data?.name)}.</p>;

  return <RenderedForm />; // server errors are shown on the fields automatically
}

(isPending reflects the in-flight submission. Passing serverAction as a <RenderedForm serverAction={...}> prop also works, but only the hook option exposes actionState.)

If you register a custom Form wrapper via config.components.Form, spread its props onto the underlying <form> so action and onSubmit reach it - a wrapper that drops them disables progressive enhancement silently. The default Form forwards them.

Validation

Built-in validation rules:

validation: {
  required: true,
  minLength: 2,
  maxLength: 100,
  pattern: 'email',        // Built-in patterns: email, url, phone, slug, username, etc.
  matchValue: 'password',  // Match another field
  requiredWhen: { field: 'type', value: 'other' },  // Conditional
  mustBeTrue: true,        // For checkboxes
  min: 0,
  max: 100,
}

HTML5 validation attributes

By default the rendered inputs carry no native HTML5 constraint attributes. Set emitHtml5Attributes: true (on the createFormDefinitionHook(...) config or the per-call config) and each input gets required, pattern, minLength/maxLength, min/max, and step derived from its validation, emitted only where they're valid for the input type. This adds a no-JS validation layer alongside react-hook-form or a server action; it's the counterpart to noValidate.

Contributing

See CONTRIBUTING.md for development setup and guidelines.

License

MIT

About

A UI-agnostic React form library that generates forms from simple field definitions with Zod validation and React Hook Form integration

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages