React Hook Form
Wire a dynz schema directly into your React forms with useDynzForm and reactive condition hooks.
The @dynz/react-hook-form package integrates dynz with React Hook Form. It provides:
useDynzForm— a drop-in replacement foruseFormthat uses the dynz resolveruseDynzFormContext— accesses the form context including the schemauseIsRequired— reactively resolves whether a field is requireduseIsIncluded— reactively resolves whether a field is includeduseIsMutable— reactively resolves whether a field is mutableuseOptions— returns the currently enabled options for an options fieldusePredicate— evaluates any predicate reactivelydynzResolver— the standalone resolver for use with plainuseForm
Installation
npm install @dynz/react-hook-form react-hook-form @hookform/resolversBasic usage
Replace useForm with useDynzForm and pass your schema:
import * as d from 'dynz';
import { useDynzForm } from '@dynz/react-hook-form';
const registrationSchema = d.object({
email: d.string().email(),
password: d.string().min(8),
plan: d.options(['free', 'pro', 'enterprise'] as const),
});
export function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useDynzForm({
schema: registrationSchema,
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<select {...register('plan')}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<button type="submit">Register</button>
</form>
);
}useDynzForm accepts all the same options as useForm — defaultValues, mode, reValidateMode — plus a few extras:
| Prop | Type | Description |
|---|---|---|
schema | ObjectSchema | The dynz schema to validate against |
currentValues | SchemaValues<T> | Existing values for update forms |
schemaOptions | ValidateOptions | Passed to validate() (e.g. customRules) |
resolverOptions.messageTransformer | (error) => string | Transform error messages before they reach RHF |
DynzField — the recommended pattern
Rather than calling useIsIncluded, useIsRequired, and useIsMutable separately in every custom input, wrap them once in a DynzField component and reuse it everywhere. DynzField handles field visibility, required state, and mutability, then delegates rendering to a render prop — keeping your input components free of dynz-specific logic.
import { useDynzFormContext, useIsMutable, useIsRequired, useIsIncluded } from '@dynz/react-hook-form';
import { Schema } from 'dynz';
import { ReactElement, useMemo } from 'react';
import {
Controller,
ControllerFieldState,
ControllerRenderProps,
FieldValues,
UseFormStateReturn,
} from 'react-hook-form';
export type DynzFieldRenderProps = {
name: string;
readOnly: boolean;
required: boolean;
schema: Schema;
field: ControllerRenderProps<FieldValues, string>;
fieldState: ControllerFieldState;
formState: UseFormStateReturn<FieldValues>;
};
export type DynzFieldProps = {
name: string;
render: (props: DynzFieldRenderProps) => ReactElement;
};
export function DynzField({ name, render }: DynzFieldProps) {
const { control, getDependencies, schema } = useDynzFormContext();
const isMutable = useIsMutable(name);
const isRequired = useIsRequired(name);
const isIncluded = useIsIncluded(name);
const dependencies = useMemo(
() => getDependencies(name),
[getDependencies, name],
);
if (isIncluded === false) return null;
return (
<div data-dynz-field={name}>
<Controller
control={control}
name={name}
rules={{ deps: dependencies }}
render={({ field, fieldState, formState }) =>
render({
name,
required: isRequired !== false,
readOnly: isMutable === false || !!field.disabled,
schema,
field,
fieldState,
formState,
})
}
/>
</div>
);
}Building inputs with DynzField
With DynzField in place, each input component receives readOnly, required, and the RHF field / fieldState objects — nothing else. No hook calls inside the input:
// A plain text input
function TextInput({ name }: { name: string }) {
return (
<DynzField
name={name}
render={({ field, fieldState, readOnly, required }) => (
<div>
<input {...field} readOnly={readOnly} aria-required={required} />
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)}
/>
);
}
// A select that renders only the currently enabled options
function SelectInput({ name }: { name: string }) {
const options = useOptions(name);
return (
<DynzField
name={name}
render={({ field, readOnly }) => (
<select {...field} disabled={readOnly}>
{options.map((option) => (
<option key={option.value.toString()} value={option.value} disabled={!option.enabled}>
{option.value}
</option>
))}
</select>
)}
/>
);
}Putting it together
const schema = d.object({
title: d.string().min(1),
slug: d.string().setMutable(false),
plan: d.options(['free', 'pro', 'enterprise'] as const),
companyName: d.string().min(1).setIncluded(d.eq(d.ref('plan'), 'enterprise')),
});
export function PostForm({ post }: { post?: Post }) {
const form = useDynzForm({
schema,
currentValues: post,
defaultValues: post,
});
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(console.log)}>
<TextInput name="title" />
<TextInput name="slug" /> {/* read-only on update — setMutable(false) */}
<SelectInput name="plan" />
<TextInput name="companyName" /> {/* hidden unless plan === 'enterprise' */}
<button type="submit">Save</button>
</form>
</FormProvider>
);
}TextInput and SelectInput have no knowledge of required state, mutability, or inclusion — DynzField handles all of that. Adding a new field to the form is a one-liner.
Update forms with mutability
Pass currentValues for update flows. Mutability rules will be enforced:
const editSchema = d.object({
title: d.string().min(1),
slug: d.string().setMutable(false), // Can't change after creation
content: d.string(),
published: d.boolean(),
});
export function EditPostForm({ post }: { post: Post }) {
const form = useDynzForm({
schema: editSchema,
currentValues: post, // ← enables mutability enforcement
defaultValues: post,
});
// ...
}Conditional fields with useIsIncluded
Use useIsIncluded to show or hide fields based on the current form state. The hook watches the relevant field dependencies and re-renders automatically — no manual watch() calls needed.
Build a generic ShortTextField that renders only when its field is included:
import { useDynzForm, useDynzFormContext, useIsIncluded } from '@dynz/react-hook-form';
function ShortTextField({ name }: { name: string }) {
const isIncluded = useIsIncluded(name);
const { register, formState: { errors } } = useDynzFormContext();
if (!isIncluded) return null;
return (
<div>
<input {...register(name)} />
{errors[name] && <span>{errors[name].message}</span>}
</div>
);
}
const schema = d.object({
plan: d.options(['free', 'pro', 'enterprise'] as const),
companyName: d.string().min(1)
.setIncluded(d.eq(d.ref('plan'), 'enterprise')),
});
export function PlanForm() {
const form = useDynzForm({ schema });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(console.log)}>
<select {...form.register('plan')}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<ShortTextField name="companyName" />
<button type="submit">Save</button>
</form>
</FormProvider>
);
}useIsRequired
Resolves whether a field is currently required, accounting for conditionals. Use it to show or hide the required indicator on a label:
function ShortTextField({ name }: { name: string }) {
const isRequired = useIsRequired(name);
const { register, formState: { errors } } = useDynzFormContext();
return (
<div>
<label>
{name} {isRequired && <span aria-hidden>*</span>}
</label>
<input {...register(name)} />
{errors[name] && <span>{errors[name].message}</span>}
</div>
);
}
// Usage
<ShortTextField name="email" />
<ShortTextField name="companyName" />useIsMutable
Resolves whether a field can be edited in an update form. Use it to disable inputs that the schema has marked immutable:
function ShortTextField({ name }: { name: string }) {
const isMutable = useIsMutable(name);
const { register } = useDynzFormContext();
return (
<input
{...register(name)}
disabled={isMutable === false}
readOnly={isMutable === false}
/>
);
}
// Usage — slug is setMutable(false), so this renders as read-only on update
<ShortTextField name="slug" />useOptions — conditional options
For d.options() fields where individual options have conditional enabled flags, useOptions returns only the currently active options. Build a generic SelectField that renders the live option list:
import { useOptions } from '@dynz/react-hook-form';
function SelectField({ name }: { name: string }) {
const options = useOptions(name);
const { register } = useDynzFormContext();
return (
<select {...register(name)}>
{options.map((option) => (
<option key={option.value.toString()} value={option.value} disabled={!option.enabled}>
{option.value}
</option>
))}
</select>
);
}
const schema = d.object({
country: d.options(['US', 'CA', 'GB'] as const),
shipping: d.options([
'standard',
{ value: 'express', enabled: d.isIn(d.ref('country'), ['US', 'CA']) },
{ value: 'overnight', enabled: d.eq(d.ref('country'), 'US') },
]),
});
// Usage — available options update automatically as country changes
<SelectField name="country" />
<SelectField name="shipping" />When country changes to 'GB', SelectField for shipping automatically renders only ['standard'].
usePredicate — arbitrary reactive conditions
Evaluate any dynz predicate reactively:
import { usePredicate } from '@dynz/react-hook-form';
function ProBadge() {
const isPro = usePredicate(d.eq(d.ref('plan'), 'pro'));
return isPro ? <span>Pro features unlocked</span> : null;
}Custom error messages
Pass a messageTransformer to map dynz error objects to display strings:
const form = useDynzForm({
schema,
resolverOptions: {
messageTransformer: (error) => {
const messages: Record<string, string> = {
'min_length': `Too short (min ${error.rule?.min} chars)`,
'email': 'Please enter a valid email address',
'required': 'This field is required',
};
return messages[error.code] ?? error.message;
},
},
});With an i18n library
messageTransformer receives the full ErrorMessage object, so you can pass any values from it as message parameters. Here's the same setup using react-i18next:
{
"error": {
"required": "This field is required",
"email": "Enter a valid email address",
"min_length": "Must be at least {{min}} characters",
"max_length": "Must be at most {{max}} characters",
"immutable": "This field cannot be changed"
}
}import { useTranslation } from 'react-i18next';
function MyForm() {
const { t } = useTranslation('form');
const form = useDynzForm({
schema,
resolverOptions: {
messageTransformer: (error) =>
t(`error.${error.code}`, { ...error.rule, defaultValue: error.message }),
},
});
// ...
}error.rule carries the rule's parameters (e.g. { min: 8 } for a min_length error), so they slot directly into the translation string as interpolation values. The defaultValue fallback ensures unknown codes still produce a readable message.
If you share the same messageTransformer across multiple forms, extract it into a hook — for example useDynzMessageTransformer() — and call it once at the top of each form component.
Using the standalone dynzResolver
If you already use useForm and just want the resolver:
import { useForm } from 'react-hook-form';
import { dynzResolver } from '@dynz/react-hook-form';
const form = useForm({
resolver: dynzResolver(schema, currentValues, schemaOptions),
});dynzResolver accepts the same arguments as the useDynzForm schema/currentValues/schemaOptions props.