@quanticjs/react-forms
v8.0.0
Published
Form hooks for QuanticJS — Zod validation, automatic server error mapping from ProblemDetails
Readme
@quanticjs/react-forms
Form hook with Zod client-side validation and automatic server error mapping from QuanticJS ProblemDetails responses.
Install
pnpm add @quanticjs/react-core @quanticjs/react-forms zoduseForm
import { useForm } from '@quanticjs/react-forms';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
});
function CreateUserForm() {
const { register, errors, isSubmitting, handleSubmit } = useForm({
schema,
defaultValues: { name: '', email: '' },
onSubmit: async (data) => {
await client.post('/users', data);
},
});
return (
<form onSubmit={handleSubmit}>
<input {...register('name')} />
{errors.name && <span>{errors.name}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email}</span>}
<button type="submit" disabled={isSubmitting}>
Create
</button>
</form>
);
}How Validation Works
- Client-side first — Zod schema validates on submit. If invalid, field errors are set immediately (no network request).
- Server call — If Zod passes,
onSubmitis called with the validated data. - Server error mapping — If
onSubmitthrows anApiErrorwithisValidation: true, field errors fromerror.fieldErrorsare automatically mapped to the form. No manual error handling needed. - Non-validation errors — If the server throws a non-validation error (404, 500, etc.), it re-throws so your error boundary or mutation handler can catch it.
Config
interface UseFormConfig<TSchema extends ZodType> {
schema: TSchema; // Zod schema
defaultValues?: Partial<Infer<TSchema>>; // Initial field values
onSubmit: (data: Infer<TSchema>) => Promise<void>; // Called with validated data
}Return Value
interface UseFormReturn<TSchema extends ZodType> {
// State
values: Partial<Infer<TSchema>>; // Current form values
errors: Record<string, string>; // Field → first error message
touched: Record<string, boolean>; // Fields the user has interacted with
isSubmitting: boolean; // True during onSubmit execution
isDirty: boolean; // True if any value changed from defaults
// Field binding
register(name: string): FieldProps; // Spread onto <input>, <textarea>, <select>
registerCheckbox(name: string): CheckboxFieldProps; // Spread onto boolean controls (checkbox, switch)
// Manual control
setValue(name: string, value: unknown): void; // Set a field value programmatically
setError(name: string, message: string): void; // Set a field error manually
clearErrors(): void; // Clear all errors
reset(values?: Partial<...>): void; // Reset to defaults or new values
handleSubmit(e?: FormEvent): Promise<void>; // Validate + submit
}register(name)
Returns props to spread onto form elements:
interface FieldProps {
name: string;
value: string;
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
onBlur: () => void;
}onChangeupdates the value and clears the field's erroronBlurmarks the field as touched
registerCheckbox(name)
register reads e.target.value (a string), so boolean controls need registerCheckbox, which reads e.target.checked:
interface CheckboxFieldProps {
name: string;
checked: boolean;
onChange: (e: ChangeEvent<HTMLInputElement>) => void; // reads e.target.checked
onBlur: () => void;
}<Checkbox {...form.registerCheckbox('agree')} />
<Switch {...form.registerCheckbox('notifications')} />The existing register signature is unchanged.
setValue(name, value)
For non-standard inputs (date pickers, rich text, toggles):
const form = useForm({ schema, onSubmit });
<DatePicker
value={form.values.date}
onChange={(date) => form.setValue('date', date)}
/>Error Display Patterns
Show errors only after touch — fieldError(form, name)
The canonical gate: returns the field's message only once the field is touched.
import { fieldError } from '@quanticjs/react-forms';
{fieldError(form, 'email') && (
<span className="text-destructive text-sm">{fieldError(form, 'email')}</span>
)}It is designed to feed FormField from @quanticjs/react-ui directly — label, description, error, and all ARIA wiring come for free:
import { FormField, Input } from '@quanticjs/react-ui';
<FormField label="Email" error={fieldError(form, 'email')}>
<Input type="email" {...form.register('email')} />
</FormField>(The packages stay decoupled: react-forms has no runtime dependency on react-ui — the integration is purely compositional.)
Show all errors on submit
All fields with errors are automatically marked as touched when validation fails — Zod errors and server-mapped errors alike — so fieldError (and the manual pattern above) shows them immediately after submit, while untouched clean fields never flash errors during typing.
Server errors appear on the right field
If the server returns:
{
"type": "https://quantic.dev/errors/VALIDATION_ERROR",
"errors": [
{ "field": "email", "message": "Email already exists" }
]
}The email field error will show "Email already exists" — no manual mapping needed.
Root-level errors
Server errors without a field are stored under _root. They are not FormField's job — render them via the existing QueryErrorPanel from @quanticjs/react-ui or an inline alert:
{errors._root && (
<div role="alert" className="text-destructive">{errors._root}</div>
)}Exports
useForm, fieldError
type UseFormConfig, type UseFormReturn, type FieldProps, type CheckboxFieldProps