Redpanda UIRedpanda UI

useProtoForm

Proto-driven form validation hook using Standard Schema and protovalidate-es.

Made by malinskibeniamin

useProtoForm 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.

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

  1. Standard Schema bridgecreateStandardSchema() 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

ScenarioRecommendation
Full control over field rendering with proto validationuseProtoForm
Automatic field rendering from proto schemasAutoForm
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 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:

MethodDescription
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.

On this page