Schema validation
BlueForm supports schema-based validation via formProps.resolver, which accepts any resolver compatible with React Hook Form — including Zod, Yup, and others.
Setup
Install your schema library and the RHF resolvers package:
# Zod
pnpm add zod @hookform/resolvers
# Yup
pnpm add yup @hookform/resolvers
Pass the resolver via formProps:
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
});
type FormData = z.infer<typeof schema>;
<Form<FormData>
formProps={{ resolver: zodResolver(schema) }}
config={{
name: { type: "text", label: "Name" },
email: { type: "text", label: "Email" },
}}
onSubmit={(data) => console.log(data)}
/>;
Resolver vs field-level rules
When formProps.resolver is provided, field-level rules are automatically disabled. BlueForm detects the conflict and strips rules from useController to prevent both running simultaneously and producing unpredictable error messages.
A dev-mode warning will appear if both are present:
[react-headless-form] `rules` defined in field config are automatically disabled
when `formProps.resolver` is provided. Define all validation in your schema instead.
Bottom line: pick one approach and stick with it.
Handling undefined field values
BlueForm fields start as undefined when untouched. Most schema libraries expect a specific type and will throw a type error instead of a custom message when they receive undefined.
The recommended fix is to provide defaultValue for each field:
config={{
name: {
type: "text",
defaultValue: "", // field starts as "" instead of undefined
},
age: {
type: "text",
defaultValue: 0,
},
}}
Alternatively, set defaultValues at the form level:
<Form
defaultValues={{ name: "", email: "", age: 0 }}
formProps={{ resolver: zodResolver(schema) }}
...
/>
Zod-specific note
z.string() does not accept undefined and will throw a type error before reaching your custom message. Use z.preprocess to coerce undefined to "":
// without defaultValue in config
const schema = z.object({
name: z.preprocess((v) => v ?? "", z.string().min(1, "Name is required")),
});
// with defaultValue: "" in config — no preprocess needed
const schema = z.object({
name: z.string().min(1, "Name is required"),
});
Yup-specific note
Yup's .required() correctly handles empty strings out of the box. Setting defaultValue: "" in field config is sufficient — no additional schema workaround needed.
Yup defaults to abortEarly: true, which stops at the first error. To show all errors simultaneously (recommended for form UX), pass { abortEarly: false }:
formProps={{ resolver: yupResolver(schema, { abortEarly: false }) }}
Example: Zod
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
type LoginData = z.infer<typeof schema>;
<Form<LoginData>
defaultValues={{ username: "", password: "" }}
formProps={{ resolver: zodResolver(schema) }}
config={{
username: { type: "text", label: "Username" },
password: { type: "text", label: "Password", props: { type: "password" } },
}}
onSubmit={(data) => console.log(data)}
>
<button type="submit">Login</button>
</Form>;
Example: Yup
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
const schema = yup.object({
username: yup.string().required("Username is required"),
password: yup
.string()
.min(6, "Password must be at least 6 characters")
.required(),
});
type LoginData = yup.InferType<typeof schema>;
<Form<LoginData>
defaultValues={{ username: "", password: "" }}
formProps={{ resolver: yupResolver(schema, { abortEarly: false }) }}
config={{
username: { type: "text", label: "Username" },
password: { type: "text", label: "Password", props: { type: "password" } },
}}
onSubmit={(data) => console.log(data)}
>
<button type="submit">Login</button>
</Form>;