@classytic/formkit
v1.3.1
Published
Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.
Maintainers
Readme
@classytic/formkit
Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.
Features
- Minimal boilerplate -
useFormKithook: 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/serverentry 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, andfieldStateprops - Validation helpers -
buildValidationRulesgenerates 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-formQuick 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: "[email protected]" }, // 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
