Dynz.

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 for useForm that uses the dynz resolver
  • useDynzFormContext — accesses the form context including the schema
  • useIsRequired — reactively resolves whether a field is required
  • useIsIncluded — reactively resolves whether a field is included
  • useIsMutable — reactively resolves whether a field is mutable
  • useOptions — returns the currently enabled options for an options field
  • usePredicate — evaluates any predicate reactively
  • dynzResolver — the standalone resolver for use with plain useForm

Installation

npm install @dynz/react-hook-form react-hook-form @hookform/resolvers

Basic 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 useFormdefaultValues, mode, reValidateMode — plus a few extras:

PropTypeDescription
schemaObjectSchemaThe dynz schema to validate against
currentValuesSchemaValues<T>Existing values for update forms
schemaOptionsValidateOptionsPassed to validate() (e.g. customRules)
resolverOptions.messageTransformer(error) => stringTransform error messages before they reach RHF

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:

locales/en/form.json
{
  "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.

On this page