useProtoForm
Proto-driven form validation hook using Standard Schema and protovalidate-es.
Made by malinskibeniaminuseProtoForm creates a react-hook-form instance with validation rules derived from buf.validate annotations in your .proto schema. No manual validation code needed — field errors are mapped automatically via protovalidate-es and the Standard Schema spec.
Use this hook when you want proto-driven validation but need full control over field rendering. For automatic field rendering from proto schemas, use AutoForm instead.
Protobuf support requires @bufbuild/protobuf v2 (protobuf-es v2) and @bufbuild/protovalidate v1. The older protobuf-es v1 API (MessageType, PlainMessage, class-based messages) is not compatible. If your project uses v1, you will need to migrate to v2 before using this hook.
Installation
Usage
import { useProtoForm } from "@/hooks/use-proto-form";
import { Button } from "@/components/button";
import { Field, FieldError, FieldLabel } from "@/components/field";
import { Input } from "@/components/input";
import { MyMessageSchema } from "./gen/my_message_pb";
function MyForm() {
const form = useProtoForm(MyMessageSchema, {
defaultValues: {
name: "",
email: "",
},
});
const onSubmit = form.handleSubmit((values) => {
console.log("Valid proto message:", values);
});
return (
<form onSubmit={onSubmit}>
<Field>
<FieldLabel>Name</FieldLabel>
<Input {...form.register("name")} placeholder="Name" />
<FieldError>{form.formState.errors.name?.message}</FieldError>
</Field>
<Field>
<FieldLabel>Email</FieldLabel>
<Input {...form.register("email")} placeholder="Email" />
<FieldError>{form.formState.errors.email?.message}</FieldError>
</Field>
<Button type="submit">Submit</Button>
</form>
);
}How it works
- Standard Schema bridge —
createStandardSchema()from@bufbuild/protovalidatewraps the proto descriptor in the universal~standardvalidation interface. - Resolver — The hook builds a react-hook-form
Resolverthat creates a proto message from form values, validates it via the standard schema, and maps issues back to field paths. - Oneof flattening — Oneof fields are type-flattened so paths like
register('config.value.apiKey')work without casts. - Eager validation — On mount,
trigger()is called soformState.isValidreflects the true validation state immediately, without waiting for user interaction.
When to use
| Scenario | Recommendation |
|---|---|
| Full control over field rendering with proto validation | useProtoForm |
| Automatic field rendering from proto schemas | AutoForm |
| Non-proto forms (manual Zod/custom validation) | useForm from react-hook-form directly |
Type-safe defaults
Use useProtoFormDefaults to generate default values from a proto schema. This creates a protobuf message with your overrides and returns it typed for the form:
import { useProtoForm, useProtoFormDefaults } from "@/hooks/use-proto-form";
import { MyMessageSchema } from "./gen/my_message_pb";
function MyForm() {
const form = useProtoForm(MyMessageSchema, {
defaultValues: useProtoFormDefaults(MyMessageSchema, {
name: "",
enabled: true,
}),
});
// ...
}This is preferred over inline defaultValues when your proto has nested messages or oneofs, since create() will correctly initialize all sub-messages.
Creating protobuf messages
The returned form includes a createMessage helper that builds a fully-typed protobuf message from current form values — ready for RPC calls:
const form = useProtoForm(MyMessageSchema);
const onSubmit = form.handleSubmit(async (values) => {
// Build a protobuf message from submitted values
const message = form.createMessage(values);
await myService.send(message);
});
// Or create a message from current form state at any time
const snapshot = form.createMessage();Oneof handling
Proto oneof fields have a { case, value } shape that does not map cleanly to react-hook-form paths. useProtoForm provides setOneofValue to switch oneof branches without casts:
import { create } from "@bufbuild/protobuf";
import { OpenAIConfigSchema } from "./gen/provider_pb";
// Switch the 'providerConfig' oneof to the 'openaiConfig' case
form.setOneofValue(
"providerConfig",
"openaiConfig",
create(OpenAIConfigSchema, { apiKeyRef: "" })
);setOneofValue validates at runtime that the target path holds a oneof-shaped value ({ case, value } or undefined). If the path points to a regular field, it throws an error with a helpful message.
Nested error access
For deeply nested fields — especially within oneofs — use getNestedErrors to drill into the error tree without casts:
const configErrors = form.getNestedErrors("providerConfig.value");
// Access individual field errors
if (configErrors?.apiKeyRef?.message) {
// show error for apiKeyRef
}This is useful when rendering error messages for oneof fields where the active case determines which nested errors exist.
Full example with oneofs
import { useProtoForm, useProtoFormDefaults } from "@/hooks/use-proto-form";
import { create } from "@bufbuild/protobuf";
import { Button } from "@/components/button";
import { Field, FieldError, FieldLabel } from "@/components/field";
import { Input } from "@/components/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/select";
import {
ProviderConfigSchema,
OpenAIConfigSchema,
AnthropicConfigSchema,
} from "./gen/provider_pb";
function ProviderForm() {
const form = useProtoForm(ProviderConfigSchema, {
defaultValues: useProtoFormDefaults(ProviderConfigSchema, {
name: "",
}),
});
const providerCase = form.watch("provider.case");
const handleProviderChange = (value: string) => {
if (value === "openai") {
form.setOneofValue("provider", "openaiConfig", create(OpenAIConfigSchema, {}));
} else if (value === "anthropic") {
form.setOneofValue("provider", "anthropicConfig", create(AnthropicConfigSchema, {}));
}
};
const providerErrors = form.getNestedErrors("provider.value");
return (
<form onSubmit={form.handleSubmit((values) => {
const message = form.createMessage(values);
console.log("Submitting:", message);
})}>
<Field>
<FieldLabel>Name</FieldLabel>
<Input {...form.register("name")} />
<FieldError>{form.formState.errors.name?.message}</FieldError>
</Field>
<Field>
<FieldLabel>Provider</FieldLabel>
<Select value={providerCase ?? ""} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</Field>
{providerCase === "openaiConfig" && (
<Field>
<FieldLabel>API Key</FieldLabel>
<Input {...form.register("provider.value.apiKeyRef")} type="password" />
<FieldError>{providerErrors?.apiKeyRef?.message}</FieldError>
</Field>
)}
{providerCase === "anthropicConfig" && (
<Field>
<FieldLabel>API Key</FieldLabel>
<Input {...form.register("provider.value.apiKeyRef")} type="password" />
<FieldError>{providerErrors?.apiKeyRef?.message}</FieldError>
</Field>
)}
<Button type="submit">Submit</Button>
</form>
);
}API
function useProtoForm<Desc extends DescMessage>(
schema: Desc,
options?: Omit<UseFormProps<FormShape<Desc>>, "resolver">
): UseProtoFormReturn<Desc>;Parameters
schema— A BufDescMessagedescriptor (e.g., the*Schemaexport from generated proto code).options— Standard react-hook-formUseFormProps, minusresolver(which is provided automatically).
Returns
A UseProtoFormReturn instance — everything from UseFormReturn plus these proto-specific helpers:
| Method | Description |
|---|---|
createMessage(values?) | Build a fully-typed protobuf message from current or provided form values. |
setOneofValue(path, case, value, options?) | Switch a oneof branch without casts. Validates at runtime that the target is a oneof field. |
getNestedErrors(path) | Drill into nested error objects without casts — useful for oneof error rendering. |
useProtoFormDefaults
function useProtoFormDefaults<Desc extends DescMessage>(
schema: Desc,
init?: MessageInitShape<Desc>
): FormShape<Desc>;Creates type-safe default values by wrapping create() from @bufbuild/protobuf. Use this to properly initialize nested messages and oneofs.
Built by malinskibeniamin. The source code is available on GitHub.