---
title: useProtoForm
description: Proto-driven form validation hook using Standard Schema and protovalidate-es.
icon: file-code
author:
  name: malinskibeniamin
  url: https://github.com/malinskibeniamin
---

<PoweredBy packages={[
  { name: 'react-hook-form', url: 'https://react-hook-form.com/' },
  { name: '@bufbuild/protobuf', url: 'https://github.com/bufbuild/protobuf-es' },
  { name: '@bufbuild/protovalidate', url: 'https://buf.build/docs/protovalidate/' },
]} />

### Installation

```package-install
npx shadcn@latest add @redpanda/use-proto-form
```

`useProtoForm` creates a [react-hook-form](https://react-hook-form.com/) 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](https://github.com/bufbuild/protovalidate-es) and the [Standard Schema](https://github.com/standard-schema/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](/docs/auto-form) instead.

:::warning
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](https://github.com/bufbuild/protobuf-es/blob/main/MANUAL.md#migrating-from-version-1) before using this hook.
:::

## Usage

```tsx showLineNumbers
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

1. **Standard Schema bridge** — `createStandardSchema()` from `@bufbuild/protovalidate` wraps the proto descriptor in the universal `~standard` validation interface.
2. **Resolver** — The hook builds a react-hook-form `Resolver` that creates a proto message from form values, validates it via the standard schema, and maps issues back to field paths.
3. **Oneof flattening** — Oneof fields are type-flattened so paths like `register('config.value.apiKey')` work without casts.
4. **Eager validation** — On mount, `trigger()` is called so `formState.isValid` reflects 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](/docs/auto-form) |
| 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:

```tsx showLineNumbers
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:

```tsx showLineNumbers
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:

```tsx showLineNumbers
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:

```tsx showLineNumbers
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

```tsx showLineNumbers expandable
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

```tsx
function useProtoForm<Desc extends DescMessage>(
  schema: Desc,
  options?: Omit<UseFormProps<FormShape<Desc>>, "resolver">
): UseProtoFormReturn<Desc>;
```

### Parameters

- **`schema`** — A Buf `DescMessage` descriptor (e.g., the `*Schema` export from generated proto code).
- **`options`** — Standard react-hook-form `UseFormProps`, minus `resolver` (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`

```tsx
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.
