useStandardSchema
A React hook for managing form state using any Standard Schema-compliant validator.
Table of contents
Overview
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—all with type safety via the Standard Schema interface.
Why useStandardSchema?
- 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"
)
Prerequisites
- React v18+
- TypeScript (optional, but recommended)
- A validator that implements the Standard Schema v1 interface
Installation
npm install use-standard-schema
# or
yarn add use-standard-schema
# or
pnpm add use-standard-schema
Usage
Example using zod. This form has two fields: firstName
and lastName
. Both are required and must be at least two characters long.
import { defineForm, useStandardSchema, type TypeFromDefinition } from "use-standard-schema"
import * as z from "zod"
const validString = z.string().min(2, "Too short").max(100, "Too long")
// create the form definition
// nested objects are supported (e.g. address.street1)
const nameForm = defineForm({
firstName: {
validate: validString, // required
label: "First Name", // required
description: "Enter your given name"
},
lastName: {
validate: validString,
label: "Last Name",
description: "Enter your surname",
defaultValue: "" // optional initial value
},
})
// get the type from the form definition
type NameFormData = TypeFromDefinition<typeof nameForm>;
export function App() {
const { getForm, getField, resetForm, getErrors } = useStandardSchema(nameForm);
const handleSubmit = (data: NameFormData) => {
console.log(data);
resetForm();
}
const form = getForm(handleSubmit)
const firstName = getField("firstName");
const lastName = getField("lastName");
const allErrors = getErrors()
return (
<form {...form}>
{/* show all errors */}
{allErrors.length > 0 && (
<div className="all-error-messages" role="alert">
{allErrors.map(({ key, error, label }) => (
<p key={key}>{label} is {error}</p>
))}
</div>
)}
<div className={`field ${firstName.error ? "has-error" : ""}`}>
<label htmlFor={firstName.name}>{firstName.label}</label>
<input
name={firstName.name}
defaultValue={firstName.defaultValue}
aria-describedby={firstName.describedById}
aria-errormessage={firstName.errorId}
/>
<p id={firstName.describedById} className="description">
{firstName.description}
</p>
<p id={firstName.errorId} className="error">
{firstName.error}
</p>
</div>
<div className={`field ${lastName.error ? "has-error" : ""}`}>
<label htmlFor={lastName.name}>{lastName.label}</label>
<input
name={lastName.name}
defaultValue={lastName.defaultValue}
aria-describedby={lastName.describedById}
aria-errormessage={lastName.errorId}
/>
<p id={lastName.describedById} className="description">
{lastName.description}
</p>
<p id={lastName.errorId} className="error">
{lastName.error}
</p>
</div>
<button type="submit">Submit</button>
</form>
)
}
Examples
Nested object field
Dot notation is supported automatically:
const addressForm = defineForm({
address: {
street1: {
validate: z.string().min(2, "Too short"),
label: "Street Address",
},
},
});
const streetField = getField("address.street1");
Valid keys
A FormDefinition
's key is an intersection between a valid JSON key and an HTML name attribute.
const definition = 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
})
Using other validators
Switching to another validator is straightforward. Simply update validate
in the form definition.
Here is the example above using Valibot.
import * as v from 'valibot'
const validString = v.pipe(
v.string(),
v.minLength(2, "Too short"),
v.maxLength(100, "Too long")
)
// showing the updated form definition for completeness.
// no real changes here
const formData = defineForm({
firstName: {
//... the same as previous example
validate: validString,
},
lastName: {
//... the same as previous example
validate: validString,
}
});
In this instance, we simply update the validString
validator from zod
to valibot
.
formDefinition
does not change.
Custom Components
It is recommended to use useStandardSchema
with your own custom React components. This enables you to simply spread the result of the getField
call directly without creating individual props. You can extend the FieldDefinitionProps
interface provided.
interface FieldProps extends FieldDefinitionProps {
// your props here
}
// or
type FieldProps = FieldDefinitionProps & {
// your props here
}
<Field {...getField("firstName")} />
<Field {...getField("lastName")} />
API
Hook / Function | Description |
---|---|
useStandardSchema(formDefinition) |
Initialize form state and validation with a form definition |
getForm(onSubmit) |
Returns event handlers for the form; submit handler only fires with valid data |
getField(name) |
Returns metadata for a given field (label, defaultValue, error, touched, dirty, ARIA ids) |
resetForm() |
Resets all form state to initial defaults |
touched |
Read-only frozen object of touched fields |
dirty |
Read-only frozen object of dirty fields |
toFormData(data) |
Helper to convert values to FormData |
getErrors(name?) |
Returns an array of { name, error, label } for field or form |
validate(name?) |
Validates either the entire form or a single field |
__dangerouslySetField(name, value) |
Sets a field’s value directly and validates it |
Best Practices
- Type Safety: Use
TypeFromDefinition<typeof form>
for your submit handler if you need type safety. This ensures your form data matches the form definition. - Error Display: Use
getErrors()
for global errors andfield.error
for field-level errors. - Performance: Handlers and derived values (
getForm
,getField
,getErrors
) are memoized internally. You don’t need extrauseMemo
unless you’re doing heavy custom work. - Reset Strategy: Call
resetForm()
after successful submission to clear touched/dirty/errors and restore defaults. - Nested Fields: Use dot notation for nested keys (e.g.
"address.street1"
). TypeScript support ensures autocomplete for these paths. - Accessibility: Always wire
describedById
anderrorId
into your markup to keep your forms screen-reader friendly.getField
providesdescribedById
anderrorId
for use witharia-describedby
and/oraria-errormessage
.- Ensures that developers can add proper screen reader support for error messages.
- Group-level errors can be presented with
role="alert"
.
Feedback & Support
If you encounter issues or have feature requests, open an issue on GitHub.
ChangeLog
- v0.2.7 - Improve error handling
- Update the return of
getErrors
to be{name, label, error}
for consistency. getErrors
will name accept an optionalname
prop and return only that error.- Add
FieldDefinitionProps
interface for easy extension for custom components.
- Update the return of
- v0.2.6 - Better error handling
- Add
label
to typeErrorEntry
. This allows users to use the label in error messages.
- Add
- v0.2.5 - Add tests.
- Add vitest and testing-library.
- Add tests for all existing functionality.
- Created a stricter
FormDefinition
type.- Keys must be an intersection of a valid json key and an html name attribute.
- v0.2.4 - Improve validation.
- remove "schema" from function names internally and externally.
- Validation is handled consistently internally.
- Update
getErrors
to return ordered{ key, error }[]
. - fix issue with resetForm not clearing form
- v0.2.3 - Fix recursion error in
isFormDefinition
that caused an infinite loop. - v0.2.2 - Fix recursion error in
flattenSchema
. - v0.2.1 - Rename
defineSchema
todefineForm
. Renameschema
tovalidate
. - v0.2.0 - Add nested object support.
- v0.1.0 - Initial release.