JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 200
  • Score
    100M100P100Q73586F
  • License MIT

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

Package Exports

  • @classytic/formkit
  • @classytic/formkit/package.json
  • @classytic/formkit/server

Readme

@classytic/formkit

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

npm License: MIT TypeScript

Features

  • Minimal boilerplate - useFormKit hook: 5 lines to set up a complete form
  • Headless - Bring your own UI components (Shadcn, MUI, Chakra, etc.)
  • Schema-driven - Define forms with JSON/TypeScript schemas, defaults extracted automatically
  • Type-safe - Full TypeScript support with generics
  • React Hook Form - Built on top of the best form library, referentially stable return values
  • React 19 - Uses modern React 19 patterns (Context as provider, ref as prop)
  • Server Components - Dedicated @classytic/formkit/server entry point for RSC
  • Variants - Support for multiple component variants
  • Conditional fields - Show/hide fields based on form values (function, DSL rules, AND/OR logic)
  • Responsive layouts - Multi-column grid layouts
  • Accessibility - Auto-generated fieldId, error, and fieldState props
  • Validation helpers - buildValidationRules generates RHF rules from schema props
  • Lightweight - ~7KB gzipped, tree-shakeable

Requirements

  • React 19.0+ (React 18 is not supported)
  • React Hook Form 7.55.0+

Installation

npm install @classytic/formkit react-hook-form
# or
pnpm add @classytic/formkit react-hook-form
# or
yarn add @classytic/formkit react-hook-form

Quick Start

1. Create Field Components

Each field component receives FieldComponentProps including error, fieldId, and the full field config:

// components/form/form-input.tsx
"use client";

import { Controller } from "react-hook-form";
import type { FieldComponentProps } from "@classytic/formkit";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function FormInput({
  control,
  field,
  label,
  placeholder,
  required,
  error,
  fieldId,
}: FieldComponentProps) {
  return (
    <Controller
      name={field.name}
      control={control}
      render={({ field: rhfField }) => (
        <div className="space-y-2">
          {label && (
            <Label htmlFor={fieldId}>
              {label}
              {required && <span className="text-red-500 ml-1">*</span>}
            </Label>
          )}
          <Input {...rhfField} id={fieldId} placeholder={placeholder} />
          {error && <p className="text-sm text-red-500">{error.message}</p>}
        </div>
      )}
    />
  );
}

2. Create Form Adapter

Register your components and layouts:

// lib/form-adapter.tsx
"use client";

import {
  FormSystemProvider,
  type ComponentRegistry,
  type LayoutRegistry,
} from "@classytic/formkit";
import { FormInput } from "@/components/form/form-input";

const components: ComponentRegistry = {
  text: FormInput,
  email: FormInput,
  password: FormInput,
  // Add more field types...
};

const layouts: LayoutRegistry = {
  section: ({ title, description, children }) => (
    <div className="space-y-4">
      {title && <h3 className="text-lg font-semibold">{title}</h3>}
      {description && <p className="text-muted-foreground">{description}</p>}
      {children}
    </div>
  ),
  grid: ({ children, cols = 1 }) => (
    <div className={`grid grid-cols-${cols} gap-4`}>{children}</div>
  ),
};

export function FormProvider({ children }: { children: React.ReactNode }) {
  return (
    <FormSystemProvider components={components} layouts={layouts}>
      {children}
    </FormSystemProvider>
  );
}

3. Use FormGenerator

// app/signup/page.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormGenerator, useFormKit, type FormSchema } from "@classytic/formkit";
import { FormProvider } from "@/lib/form-adapter";

const signupSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

type SignupData = z.infer<typeof signupSchema>;

const formSchema: FormSchema<SignupData> = {
  sections: [
    {
      title: "Personal Information",
      cols: 2,
      fields: [
        {
          name: "firstName",
          type: "text",
          label: "First Name",
          required: true,
          defaultValue: "",
        },
        {
          name: "lastName",
          type: "text",
          label: "Last Name",
          required: true,
          defaultValue: "",
        },
      ],
    },
    {
      title: "Account",
      fields: [
        {
          name: "email",
          type: "email",
          label: "Email",
          required: true,
          defaultValue: "",
        },
        {
          name: "password",
          type: "password",
          label: "Password",
          required: true,
          defaultValue: "",
        },
      ],
    },
  ],
};

export default function SignupPage() {
  const { handleSubmit, generatorProps } = useFormKit({
    schema: formSchema,
    resolver: zodResolver(signupSchema),
  });

  return (
    <FormProvider>
      <form onSubmit={handleSubmit(console.log)} className="space-y-8">
        <FormGenerator {...generatorProps} />
        <button type="submit">Sign Up</button>
      </form>
    </FormProvider>
  );
}

API Reference

useFormKit

Convenience hook that combines schema default extraction with react-hook-form setup. Returns all useForm methods plus ready-to-spread generatorProps.

Referentially stable — the return value preserves the original useForm object identity across re-renders, so it's safe to use in useEffect dependency arrays.

import { useFormKit, FormGenerator } from "@classytic/formkit";

const form = useFormKit({
  schema: formSchema,
  resolver: zodResolver(validationSchema), // optional
  defaultValues: { email: "pre@fill.com" }, // optional overrides
  disabled: false, // optional
  variant: "compact", // optional
  className: "my-form", // optional
  mode: "onBlur", // any useForm option
});

const { handleSubmit, generatorProps } = form;

// Safe to use in useEffect deps — form is referentially stable
useEffect(() => {
  if (open) form.reset(defaults);
}, [open, form]);

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <FormGenerator {...generatorProps} />
    <button type="submit">Submit</button>
  </form>
);

Schema defaultValue fields are automatically extracted and merged with any explicit defaultValues you provide (explicit values take priority).

generatorProps is memoized — it only recomputes when schema, control, disabled, variant, or className change.

FormGenerator

The main component that renders forms from a schema. Supports React 19 ref as a regular prop.

<FormGenerator
  schema={formSchema} // Required: Form schema
  control={form.control} // Optional: React Hook Form control (or wrap in <FormProvider>)
  disabled={false} // Optional: Disable all fields
  variant="default" // Optional: Global variant
  className="my-form" // Optional: Root element class
  ref={formRef} // Optional: Ref to the root <div> (React 19 ref-as-prop)
/>

FormSchema

interface FormSchema<T extends FieldValues = FieldValues> {
  sections: Section<T>[];
}

Section

interface Section<T> {
  id?: string; // Unique identifier
  title?: string; // Section title
  description?: string; // Section description
  icon?: ReactNode; // Section icon
  fields?: BaseField<T>[]; // Fields in this section
  cols?: number; // Grid columns (1-6)
  gap?: number; // Grid gap
  variant?: string; // Section variant
  className?: string; // Custom class
  collapsible?: boolean; // Make section collapsible
  defaultCollapsed?: boolean;
  nameSpace?: string; // Prefix for nested object fields (e.g. "address")

  // Conditional rendering (function, DSL rule, or ConditionConfig)
  condition?: Condition<T>;

  // Custom render function (bypasses grid layout)
  render?: (props: SectionRenderProps<T>) => ReactNode;
}

BaseField

interface BaseField<T> {
  name: string; // Field name (required)
  type: FieldType; // Field type (required)
  label?: string; // Field label
  placeholder?: string; // Placeholder text
  helperText?: string; // Helper text below field
  disabled?: boolean; // Disable field
  required?: boolean; // Mark as required
  readOnly?: boolean; // Read-only field
  variant?: string; // Field variant
  fullWidth?: boolean; // Span full grid width
  className?: string; // Custom class
  defaultValue?: unknown; // Default value

  // Conditional rendering
  condition?: Condition<T>;
  watchNames?: string | string[]; // Optimize useWatch performance

  // Dynamic options loading
  loadOptions?: (
    formValues: Partial<T>,
  ) => Promise<FieldOption[]> | FieldOption[];
  debounceMs?: number;

  // For array/grouped types
  itemFields?: BaseField<T>[];

  // For select/radio/checkbox
  options?: FieldOption[];

  // HTML input attributes
  min?: number | string;
  max?: number | string;
  step?: number;
  pattern?: string;
  minLength?: number;
  maxLength?: number;
  rows?: number;
  multiple?: boolean;
  accept?: string;
  autoComplete?: string;
  autoFocus?: boolean;

  // Custom render override
  render?: (props: FieldComponentProps<T>) => ReactNode;

  // Arbitrary extra props for custom components
  customProps?: Record<string, unknown>;
}

FieldComponentProps

Props passed to your field components:

interface FieldComponentProps<
  T extends FieldValues = FieldValues,
> extends BaseField<T> {
  field: BaseField<T>; // Full field config
  control: Control<T>; // React Hook Form control
  disabled?: boolean; // Merged disabled state
  variant?: string; // Active variant
  error?: FieldError; // Field error from react-hook-form
  fieldState?: {
    // Field state metadata
    invalid: boolean;
    isDirty: boolean;
    isTouched: boolean;
    isValidating: boolean;
    error?: FieldError;
  };
  fieldId: string; // Generated ID for label-input association (e.g. "formkit-field-email")
}

Condition Types

Conditions can be a function, a DSL rule, an array of rules (AND), or a ConditionConfig (AND/OR):

// Function condition
condition: (values) => values.accountType === "business"

// Single DSL rule
condition: { watch: "country", operator: "===", value: "US" }

// Array of rules (AND - all must match)
condition: [
  { watch: "country", operator: "===", value: "US" },
  { watch: "age", operator: "truthy" },
]

// ConditionConfig with OR logic
condition: {
  rules: [
    { watch: "country", operator: "===", value: "US" },
    { watch: "country", operator: "===", value: "CA" },
  ],
  logic: "or",
}

Supported operators: ===, !==, in, not-in, truthy, falsy

Nested paths: DSL rules support dot-notation paths like "address.city" for nested form values.

ComponentRegistry

const components: ComponentRegistry = {
  // Simple mapping
  text: FormInput,
  select: FormSelect,

  // Variant-specific components
  compact: {
    text: CompactInput,
    select: CompactSelect,
  },
};

LayoutRegistry

const layouts: LayoutRegistry = {
  section: SectionLayout,
  grid: GridLayout,

  // Variant-specific layouts
  compact: {
    section: CompactSection,
  },
};

extractDefaultValues

Extracts default values from a schema. Server-safe (no hooks).

import { extractDefaultValues } from "@classytic/formkit"; // or /server

const defaults = extractDefaultValues(formSchema);
// { firstName: "", lastName: "", email: "", password: "" }

// Use with react-hook-form
const form = useForm({ defaultValues: defaults });

Respects nameSpace prefixes and group itemFields defaults.

buildValidationRules

Generates react-hook-form validation rules from a field's schema props. Server-safe (no hooks).

import { buildValidationRules } from "@classytic/formkit"; // or /server

function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
  const rules = buildValidationRules(field);
  return (
    <Controller
      name={field.name}
      control={control}
      rules={rules}
      render={({ field: rhf }) => <input {...rhf} id={fieldId} />}
    />
  );
}

Maps required, min, max, minLength, maxLength, and pattern from the field schema to RHF-compatible rules with auto-generated error messages.

Server Components

The @classytic/formkit/server entry point exports server-safe utilities with no React hooks or client-side code:

import {
  cn,
  defineSchema,
  defineField,
  defineSection,
  evaluateCondition,
  extractWatchNames,
  extractDefaultValues,
  buildValidationRules,
} from "@classytic/formkit/server";

// Type-only imports also available
import type {
  FormSchema,
  BaseField,
  Section,
  ConditionRule,
  ConditionConfig,
} from "@classytic/formkit/server";

Use this entry point in React Server Components to define schemas, evaluate conditions, or use cn without pulling in client-side code.

Advanced Features

Conditional Fields (Function)

{
  name: "companyName",
  type: "text",
  label: "Company Name",
  condition: (values) => values.accountType === "business",
}

Conditional Fields (DSL Rules)

{
  name: "stateField",
  type: "select",
  label: "State",
  condition: { watch: "country", operator: "===", value: "US" },
  watchNames: ["country"], // Optimizes re-renders
}

Conditional Sections

{
  title: "Business Details",
  condition: (values) => values.accountType === "business",
  fields: [
    { name: "companyName", type: "text", label: "Company" },
    { name: "taxId", type: "text", label: "Tax ID" },
  ],
}

OR Conditions

{
  name: "taxField",
  type: "text",
  condition: {
    rules: [
      { watch: "country", operator: "===", value: "US" },
      { watch: "country", operator: "===", value: "CA" },
    ],
    logic: "or",
  },
}

Nested Path Conditions

DSL rules resolve dot-notation paths for nested form values:

{
  name: "zipCode",
  type: "text",
  condition: { watch: "address.country", operator: "===", value: "US" },
}

Namespace Support

Prefix all field names in a section with a namespace for nested objects:

{
  nameSpace: "address",
  fields: [
    { name: "street", type: "text" },  // Becomes "address.street"
    { name: "city", type: "text" },    // Becomes "address.city"
  ],
}

Variants

Apply different styles based on context:

// Register variant-specific components
const components = {
  text: DefaultInput,
  compact: {
    text: CompactInput,
  },
};

// Use variant on the whole form
<FormGenerator schema={schema} variant="compact" />

// Or per-section
{ variant: "compact", fields: [...] }

// Or per-field
{ name: "notes", type: "text", variant: "compact" }

Dynamic Options Loading

{
  name: "city",
  type: "select",
  watchNames: ["country"],
  loadOptions: async (values) => {
    const cities = await fetchCities(values.country);
    return cities.map(c => ({ label: c.name, value: c.id }));
  },
  debounceMs: 300,
}

Custom Section Render

{
  title: "Payment",
  render: ({ control, disabled }) => (
    <StripeElements>
      <CardElement />
      <FormInput name="billingName" control={control} />
    </StripeElements>
  ),
}

Custom Field Render

{
  name: "avatar",
  type: "file",
  render: ({ field, control, error, fieldId }) => (
    <AvatarUploader fieldId={fieldId} error={error} />
  ),
}

Custom Props

Pass arbitrary props to your field components via customProps:

{
  name: "bio",
  type: "textarea",
  label: "Biography",
  customProps: {
    maxCharacters: 500,
    showCounter: true,
  },
}

Access in your component:

function FormTextarea({ field, customProps, ...props }: FieldComponentProps) {
  const maxChars = customProps?.maxCharacters as number;
  // ...
}

Grouped Select Options

{
  name: "country",
  type: "select",
  options: [
    {
      label: "North America",
      options: [
        { value: "us", label: "United States" },
        { value: "ca", label: "Canada" },
      ],
    },
    {
      label: "Europe",
      options: [
        { value: "uk", label: "United Kingdom" },
        { value: "de", label: "Germany" },
      ],
    },
  ],
}

Schema Builder Utilities

Type-safe helpers for defining schemas outside of components:

import {
  defineSchema,
  defineField,
  defineSection,
} from "@classytic/formkit/server";

const emailField = defineField<MyFormData>({
  name: "email",
  type: "email",
  label: "Email Address",
  required: true,
});

const personalSection = defineSection<MyFormData>({
  title: "Personal Info",
  cols: 2,
  fields: [emailField],
});

const schema = defineSchema<MyFormData>({
  sections: [personalSection],
});

Type Exports

import type {
  // Core
  FormSchema,
  FormGeneratorProps,
  BaseField,
  Section,

  // Components
  FieldComponentProps,
  FieldComponent,
  ComponentRegistry,

  // Layouts
  SectionLayoutProps,
  GridLayoutProps,
  LayoutComponent,
  LayoutRegistry,

  // Options
  FieldOption,
  FieldOptionGroup,

  // Conditions
  ConditionRule,
  ConditionConfig,
  Condition,

  // Hook types
  UseFormKitOptions,
  UseFormKitReturn,

  // Utility types
  FieldType,
  LayoutType,
  Variant,
  DefineField,
  InferSchemaValues,
  SchemaFieldNames,
  FormElement,
} from "@classytic/formkit";

Browser Support

  • React 19.0+
  • All modern browsers

License

MIT © Classytic