Overview
@contract-kit/react-hook-form provides seamless integration with React Hook Form, automatically generating form validation from your contract’s body schema. It works with any Standard Schema library including Zod, Valibot, and ArkType.
Form validation is derived directly from your contract’s body schema,
ensuring your forms stay in sync with your API contracts.
Installation
npm install @contract-kit/react-hook-form react-hook-form @hookform/resolvers
Requires TypeScript 5.0+ for proper type inference.
Quick Start
Create a form with automatic validation from your contract:
import { rhf } from " @contract-kit/react-hook-form " ;
import { createTodo } from " @/contracts/todos " ;
function CreateTodoForm () {
// Create form helper from contract
const { useForm } = rhf (createTodo);
// Use the form with automatic validation
const form = useForm ({
defaultValues : {
title : "" ,
description : "" ,
},
});
const onSubmit = form. handleSubmit (( values ) => {
// values is fully typed and validated
console. log ( " Creating todo: " , values);
});
return (
< form onSubmit = {onSubmit}>
< div >
< label htmlFor = " title " >Title *</ label >
< input
id = " title "
{ ... form. register ( " title " )}
placeholder = " What needs to be done? "
/>
{form.formState.errors.title && (
< span className = " error " >
{form.formState.errors.title.message}
</ span >
)}
</ div >
< div >
< label htmlFor = " description " >Description</ label >
< textarea
id = " description "
{ ... form. register ( " description " )}
placeholder = " Optional details "
/>
{form.formState.errors.description && (
< span className = " error " >
{form.formState.errors.description.message}
</ span >
)}
</ div >
< button type = " submit " disabled = {form.formState.isSubmitting}>
{form.formState.isSubmitting ? " Creating... " : " Create Todo " }
</ button >
</ form >
);
}
Usage
With React Query Mutations
Combine with React Query for complete data management:
import { useMutation, useQueryClient } from " @tanstack/react-query " ;
import { rhf } from " @contract-kit/react-hook-form " ;
import { rq } from " @/lib/rq " ;
import { createTodo, listTodos } from " @/contracts/todos " ;
function CreateTodoForm () {
const queryClient = useQueryClient ();
// Form with validation
const { useForm } = rhf (createTodo);
const form = useForm ({
defaultValues : { title : "" , description : "" },
});
// Mutation to create todo
const createMutation = useMutation (
rq (createTodo). mutationOptions ({
onSuccess : () => {
// Invalidate list after creating
queryClient. invalidateQueries ({
queryKey : rq (listTodos). key (),
});
// Reset form
form. reset ();
},
})
);
const onSubmit = form. handleSubmit (( values ) => {
createMutation. mutate ({ body : values });
});
return (
< form onSubmit = {onSubmit}>
< input { ... form. register ( " title " )} placeholder = " Title " />
{form.formState.errors.title && (
< p className = " error " >{form.formState.errors.title.message}</ p >
)}
< textarea { ... form. register ( " description " )} placeholder = " Description " />
{form.formState.errors.description && (
< p className = " error " >{form.formState.errors.description.message}</ p >
)}
< button type = " submit " disabled = {createMutation.isPending}>
{createMutation.isPending ? " Creating... " : " Create " }
</ button >
{createMutation.error && (
< p className = " error " >{createMutation.error.message}</ p >
)}
</ form >
);
}
Populate forms with existing data for editing:
import { useQuery, useMutation } from " @tanstack/react-query " ;
import { rhf } from " @contract-kit/react-hook-form " ;
import { rq } from " @/lib/rq " ;
import { getTodo, updateTodo } from " @/contracts/todos " ;
function EditTodoForm ({ id } : { id : string }) {
// Fetch existing todo
const { data : todo, isLoading } = useQuery (
rq (getTodo). queryOptions ({ path : { id } })
);
// Form with validation
const { useForm } = rhf (updateTodo);
const form = useForm ({
defaultValues : {
title : todo?.title ?? "" ,
description : todo?.description ?? "" ,
completed : todo?.completed ?? false ,
},
});
// Update mutation
const updateMutation = useMutation (
rq (updateTodo). mutationOptions ()
);
const onSubmit = form. handleSubmit (( values ) => {
updateMutation. mutate ({
path : { id },
body : values,
});
});
if (isLoading) return < div >Loading...</ div >;
return (
< form onSubmit = {onSubmit}>
< input { ... form. register ( " title " )} />
{form.formState.errors.title && (
< span >{form.formState.errors.title.message}</ span >
)}
< textarea { ... form. register ( " description " )} />
< label >
< input type = " checkbox " { ... form. register ( " completed " )} />
Completed
</ label >
< button
type = " submit "
disabled = { ! form.formState.isDirty || updateMutation.isPending}
>
{updateMutation.isPending ? " Saving... " : " Save Changes " }
</ button >
</ form >
);
}
Handle complex form structures with nested objects:
import { rhf } from " @contract-kit/react-hook-form " ;
import { createContractGroup } from " @contract-kit/core " ;
import { z } from " zod " ;
// Create contract group
const profile = createContractGroup ();
// Define schemas
const profileSchema = z. object ({
id : z. string (),
name : z. string (),
email : z. string (),
preferences : z. object ({
notifications : z. boolean (),
theme : z. enum ([ " light " , " dark " ]),
}),
});
// Contract with nested schema
const updateProfile = profile
. put ( " /api/profile " )
. body (
z. object ({
name : z. string (). min ( 1 ),
email : z. string (). email (),
preferences : z. object ({
notifications : z. boolean (),
theme : z. enum ([ " light " , " dark " ]),
}),
})
)
. response ( 200 , profileSchema);
function ProfileForm () {
const { useForm } = rhf (updateProfile);
const form = useForm ({
defaultValues : {
name : "" ,
email : "" ,
preferences : {
notifications : true ,
theme : " light " ,
},
},
});
return (
< form onSubmit = {form. handleSubmit (( values ) => console. log (values))}>
< input { ... form. register ( " name " )} placeholder = " Name " />
{form.formState.errors.name && (
< span >{form.formState.errors.name.message}</ span >
)}
< input { ... form. register ( " email " )} type = " email " placeholder = " Email " />
{form.formState.errors.email && (
< span >{form.formState.errors.email.message}</ span >
)}
< label >
< input
type = " checkbox "
{ ... form. register ( " preferences.notifications " )}
/>
Enable notifications
</ label >
< select { ... form. register ( " preferences.theme " )}>
< option value = " light " >Light</ option >
< option value = " dark " >Dark</ option >
</ select >
< button type = " submit " >Save Profile</ button >
</ form >
);
}
Access and utilize form state for better UX:
import { rhf } from " @contract-kit/react-hook-form " ;
import { createTodo } from " @/contracts/todos " ;
function CreateTodoForm () {
const { useForm } = rhf (createTodo);
const form = useForm ({
defaultValues : { title : "" },
});
const {
formState : {
errors,
isSubmitting,
isDirty,
isValid,
dirtyFields,
touchedFields,
},
} = form;
const onSubmit = ( data : { title : string }) => {
// Handle form submission
console. log ( " Form submitted: " , data);
};
return (
< form onSubmit = {form. handleSubmit (onSubmit)}>
< input { ... form. register ( " title " )} />
{ /* Show errors */ }
{errors.title && (
< span className = " error " >{errors.title.message}</ span >
)}
{ /* Conditional button state */ }
< button
type = " submit "
disabled = { ! isDirty || ! isValid || isSubmitting}
>
{isSubmitting ? " Submitting... " : " Submit " }
</ button >
{ /* Show dirty indicator */ }
{isDirty && < span >You have unsaved changes</ span >}
</ form >
);
}
Disabling Validation
Disable schema validation when needed (e.g., for partial form handling):
import { rhf } from " @contract-kit/react-hook-form " ;
import { createTodo } from " @/contracts/todos " ;
function PartialTodoForm () {
const { useForm } = rhf (createTodo);
const form = useForm ({
resolverEnabled : false , // Disable automatic schema validation
defaultValues : { title : "" },
});
// Add custom validation if needed
const onSubmit = form. handleSubmit (( values ) => {
// Custom validation logic here
console. log (values);
});
return < form onSubmit = {onSubmit}>{ /* form fields */ }</ form >;
}
API Reference
rhf(contract)
Creates a React Hook Form adapter for a contract.
const adapter = rhf (createTodo);
Parameters:
contract - A contract created with Contract Kit, or its config object
Returns: An adapter object with a useForm method
Returns a React Hook Form useForm result with the contract’s body schema as resolver.
const form = adapter. useForm ({
defaultValues? : { ... },
resolverEnabled? : boolean, // default: true
// ...other React Hook Form options
});
Options:
defaultValues - Initial form values (typed based on contract body schema)
resolverEnabled - Enable/disable automatic schema validation (default: true)
All standard React Hook Form options (mode, reValidateMode, etc.)
Returns: Standard React Hook Form result with methods:
register(name) - Register form fields
handleSubmit(onValid, onInvalid?) - Handle form submission
formState - Form state object with errors, validation status, etc.
reset(), setValue(), getValues(), and all other React Hook Form methods
Type Inference
Form fields are automatically typed based on your contract’s body schema:
// Contract definition
const createTodo = todos
. post ( " /api/todos " )
. body (
z. object ({
title : z. string (). min ( 1 ),
description : z. string (). optional (),
completed : z. boolean (). optional (),
})
)
. response ( 201 , todoSchema);
// Form values are inferred
const { useForm } = rhf (createTodo);
const form = useForm ();
form. register ( " title " ); // ✓ Valid
form. register ( " description " ); // ✓ Valid
form. register ( " completed " ); // ✓ Valid
form. register ( " invalid " ); // ✗ TypeScript error
Validation Behavior
The schema resolver validates form data at these points:
On blur - When a field loses focus
On change - After the first submission attempt
On submit - Before calling your submit handler
Validation errors are available via form.formState.errors:
{form.formState.errors.title && (
< span className = " error " >
{form.formState.errors.title.message}
</ span >
)}
Standard Schema Support
This package uses the @hookform/resolvers/standard-schema resolver, which works with any Standard Schema compatible library:
Complete Example
Here’s a full example combining all features:
import { useMutation, useQueryClient } from " @tanstack/react-query " ;
import { rhf } from " @contract-kit/react-hook-form " ;
import { rq } from " @/lib/rq " ;
import { createTodo, listTodos } from " @/contracts/todos " ;
function CreateTodoForm () {
const queryClient = useQueryClient ();
// Form setup
const { useForm } = rhf (createTodo);
const form = useForm ({
defaultValues : {
title : "" ,
description : "" ,
},
mode : " onBlur " , // Validate on blur
});
// Mutation setup
const createMutation = useMutation (
rq (createTodo). mutationOptions ({
onSuccess : () => {
// Invalidate queries and reset form
queryClient. invalidateQueries ({
queryKey : rq (listTodos). key (),
});
form. reset ();
},
onError : ( error ) => {
// Handle server errors
form. setError ( " root " , {
type : " server " ,
message : error.message,
});
},
})
);
// Submit handler
const onSubmit = form. handleSubmit (( values ) => {
createMutation. mutate ({ body : values });
});
const { errors, isDirty, isValid, isSubmitting } = form.formState;
return (
< form onSubmit = {onSubmit}>
< div className = " form-group " >
< label htmlFor = " title " >Title *</ label >
< input
id = " title "
{ ... form. register ( " title " )}
placeholder = " What needs to be done? "
aria-invalid = { !! errors.title}
/>
{errors.title && (
< span className = " error " role = " alert " >
{errors.title.message}
</ span >
)}
</ div >
< div className = " form-group " >
< label htmlFor = " description " >Description</ label >
< textarea
id = " description "
{ ... form. register ( " description " )}
placeholder = " Optional details "
rows = { 3 }
/>
{errors.description && (
< span className = " error " role = " alert " >
{errors.description.message}
</ span >
)}
</ div >
< button
type = " submit "
disabled = { ! isDirty || ! isValid || createMutation.isPending}
>
{createMutation.isPending ? " Creating... " : " Create Todo " }
</ button >
{ /* Server error display */ }
{errors.root && (
< div className = " error " role = " alert " >
{errors.root.message}
</ div >
)}
{ /* Unsaved changes warning */ }
{isDirty && ! isSubmitting && (
< p className = " warning " >You have unsaved changes</ p >
)}
</ form >
);
}
Best Practices
Always show validation errors
Display error messages next to form fields so users know what to fix.
Use form.formState.errors to access validation errors.
Provide visual feedback for form state
Use isDirty, isSubmitting, isValid to disable buttons and show
loading states for better UX.
Reset forms after successful submission
Call form.reset() after successful mutations to clear the form and
prepare for new input.
Combine with React Query for data management
Use React Query mutations with React Hook Form for complete control over
form submission, error handling, and cache invalidation.
Use mode='onBlur' for better performance
Configure validation mode to balance between user experience and performance.
onBlur mode provides immediate feedback without excessive re-renders.
Handle server errors gracefully
Use form.setError() to display server-side validation errors alongside
client-side validation errors.