A React hook for managing form state using any Standard Schema-compliant validator.
useStandardSchema wraps a Standard Schema-compliant form definition (e.g. Zod, Valibot, ArkType, etc.) into a React hook for form handling. It streamlines validation, state, error handling, and submission with type safety via the Standard Schema interface.
- Works with any validator that implements the Standard Schema spec
- Provides consistent form APIs regardless of validation library
- Built with TypeScript support, ensuring type-safe validation and form usage
- Integrates easily into React workflows
- Supports nested objects with dot notation (e.g.
"address.street1")
- React v18+
- TypeScript (optional, but recommended)
- A validator that implements the Standard Schema v1 interface
npm install use-standard-schema
# or
yarn add use-standard-schema
# or
pnpm add use-standard-schemaDefine your form once with defineForm, then consume it inside a component with useStandardSchema.
import { defineForm, useStandardSchema, type TypeFromDefinition } from "use-standard-schema"
import * as z from "zod"
const subscriptionForm = defineForm({
email: {
label: "Email",
validate: z.email("Enter a valid email address"),
defaultValue: "", // optional
description: "We'll send occasional updates.", // optional
},
})
function onSubmitHandler (values: TypeFromDefinition<typeof subscriptionForm>) {
console.log("Submitted:", values)
}
export function SubscriptionPage() {
const { getForm, getField } = useStandardSchema(subscriptionForm)
const formHandlers = getForm(onSubmitHandler)
const email = getField("email")
return (
<form {...formHandlers}>
<label htmlFor={email.name}>{email.label}</label>
<input
id={email.name}
name={email.name}
defaultValue={email.defaultValue}
// value={defaultValue}
aria-describedby={email.describedById}
aria-errormessage={email.errorId}
/>
<p id={email.describedById}>{email.description}</p>
<p id={email.errorId} role="alert">{email.error}</p>
<button type="submit">Subscribe</button>
</form>
)
}getForm(onSubmit): Returns event handlers for the<form>.onSubmitonly runs when valid.getField(name): Returns the given field's metadata.
Additional examples are available.
- CodeSandbox Demo - Try the hook in a live React playground.
- Dependent Fields example - An example that keeps two related fields in sync using
setFieldandsetError. - Custom Component example - Share reusable inputs via
FieldData. - Valibot example - Build a simple login form powered by Valibot validators.
- Shadcn Field example - Wire
useStandardSchemametadata into the shadcn/uiFieldprimitives.
Nested objects are supported.
import { defineForm } from "use-standard-schema"
import * as z from "zod"
const addressForm = defineForm({
address: {
street1: { label: "Street", validate: z.string().min(2) },
},
})useStandardSchema returns the getErrors method that returns all of the current validations errors. This can be useful for giving all form error messages in one location. NOTE: This is in addition to the getField method which returns the errors for a given field.
import type { ErrorEntry } from "use-standard-schema"
const { getErrors } = useStandardSchema(loginForm)
const errors = getErrors()
{errors.length > 0 && (
<div role="alert">
{errors.map(({ name, label, error }: ErrorEntry) => (
<p key={name}>{label}: {error}</p>
))}
</div>
)}Use the isTouched and isDirty helper methods to check whether or not the form, or a given field, has been modified or focused by the user.
const { isTouched, isDirty } = useStandardSchema(addressForm)
const isStreetTouched = isTouched("address.street1")
const isStreetDirty = isDirty("address.street1")
const isFormTouched = isTouched()
const isFormDirty = isDirty()A FormDefinition's key is an intersection between a valid JSON key and an HTML name attribute.
const formDefinition = defineForm({
prefix: z.string(), // valid
"first-name": z.string(), // valid
"middle_name": z.string(), // valid
"last:name": z.string(), // valid
"street address": z.string() // invalid
})useStandardSchema returns a helpers for wiring form elements, reading state, and issuing manual updates.
Passing a form definition using defineForm and pass the definition to the hook. The return value exposes the rest of the helpers documented below.
const { getForm, getField, getErrors, setField, setError, resetForm, isTouched, isDirty, watchValues } =
useStandardSchema(myFormDefinition)Returns the event handlers for the <form>. It validates all fields and only invokes your handler when everything passes.
const form = getForm((values) => console.log(values))
return <form {...form}>...</form>Returns metadata for a specific field for wiring inputs, labels, and helper text.
const email = getField("email")
<input
name={email.name}
defaultValue={email.defaultValue}
aria-describedby={email.describedById}
aria-errormessage={email.errorId}
aria-invalid={!!email.error}
/>
<span id={email.describedById}>Enter your email address</span>
<span id={email.errorId}>{email.error}</span>Returns structured error data of type ErrorEntry for the whole form or for one specific field - perfect for summary banners or toast notifications.
const allErrors = getErrors()
const emailErrors = getErrors("email")Clears errors, touched/dirty flags, and restores the original defaults. Note: The hook calls this automatically after a successful submit.
<button type="reset" onClick={resetForm}>Reset<button>Report whether a field - or any field when called without arguments - has been interacted with or changed.
const hasEditedAnything = isDirty()
const isEmailTouched = isTouched("email")Subscribe to canonical form values without forcing extra React renders. The callback executes whenever any watched key changes and receives an object scoped to those fields.
targets(optional): a single field name or array of field names. Omit to observe every value in the form.callback(values): invoked with the latest values for the watched fields.
unsubscribe(): stop listening insideuseEffectcleanups or teardown handlers.
const postToPreview = ({ plan, seats }) => {
previewChannel.postMessage({
quote: calculateQuote(plan, Number(seats))
})
}
useEffect(() => {
const unsubscribe = watchValues(["plan", "seats"], postToPreview)
return unsubscribe
}, [watchValues])Helper that converts a values object into a browser FormData instance for interoperability with fetch/XHR uploads.
const formData = toFormData(values)Updates a field's value (for dependent fields, custom widgets, or multi-step wizards) and re-validates it. NOTE: You do not need to call this manually in most situations. It will occur automatically.
setField("address.postalCode", nextPostalCode)Sets a manual error message for any field (for dependent fields, custom widgets, or multi-step wizards). Pass null or undefined to clear it. NOTE: You do not need to call this manually in most situations. It will occur automatically.
setError("email", new Error("Email already registered"))If you encounter issues or have feature requests, open an issue on GitHub.
- v0.4.3
- Fixed documentation issues.
- Fixed missing
ErrorEntryexport.
- v0.4.2
- Added
watchValuesfor monitoring value changes without rerender. - Fixed issue with
ErrorInfonot being exported. - Field updates are safer, validation errors fall back to helpful defaults, and async checks no longer overwrite newer input.
- Added a shadcn/ui Field example
- Added additional tests to keep real-world flows covered.
- Added
- v0.4.1 - Minor code fixes and documentation updates
- v0.4.0 - Improved form state synchronization, renamed the
FieldDefinitionPropstype toFieldData, and ensured programmatic updates stay validated while tracking touched/dirty status. - View the full changelog for earlier releases.