Skip to main content

React Hook Form

Connect Vortex form fields to React Hook Form for form state and validation.

bun add react-hook-form

Connect a field

Vortex fields call onChange with an object, such as { name, value, event }, instead of an event. Wrap each field in Controller and pass the value to field.onChange:

SignupForm.tsx
"use client";

import { Button, Stack, TextField } from "@vortexlabs/vortex";
import { Controller, useForm } from "react-hook-form";

type FormValues = {
  email: string;
};

const SignupForm = () => {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: { email: "" },
  });

  const onSubmit = (values: FormValues) => {
    console.log(values);
  };

  return (
    <form noValidate onSubmit={handleSubmit(onSubmit)}>
      <Stack gap={4}>
        <Controller
          name="email"
          control={control}
          rules={{ required: "Enter your email." }}
          render={({ field, fieldState }) => (
            <TextField.Root error={fieldState.invalid}>
              <TextField.Label>Email</TextField.Label>
              <TextField.Input
                ref={field.ref}
                name={field.name}
                value={field.value}
                onChange={({ value }) => field.onChange(value)}
                onBlur={field.onBlur}
                attributes={{ type: "email" }}
              />
              <TextField.ErrorMessage>{fieldState.error?.message}</TextField.ErrorMessage>
            </TextField.Root>
          )}
        />
        <Button type="submit" variant="primary">
          Sign up
        </Button>
      </Stack>
    </form>
  );
};

export default SignupForm;
  • fieldState.invalid sets the root's error, which shows TextField.ErrorMessage.
  • field.ref lets React Hook Form focus the first invalid field on submit.
  • TextArea connects the same way.

Other fields

Pass checked for Checkbox and Switch:

<Controller
  name="terms"
  control={control}
  render={({ field, fieldState }) => (
    <Checkbox
      ref={field.ref}
      name={field.name}
      label="I accept the terms"
      helperText={fieldState.error?.message}
      error={fieldState.invalid}
      checked={field.value}
      onChange={({ checked }) => field.onChange(checked)}
    />
  )}
/>

Select reports the selected value through onValueChange:

<Controller
  name="role"
  control={control}
  render={({ field, fieldState }) => (
    <Select.Root error={fieldState.invalid}>
      <Select.Label>Role</Select.Label>
      <Select.Field
        value={field.value}
        onValueChange={(value) => field.onChange(value ?? "")}
        onClose={field.onBlur}
        loadOptions={loadRoles}
        placeholder="Choose a role"
      />
      <Select.ErrorMessage>{fieldState.error?.message}</Select.ErrorMessage>
    </Select.Root>
  )}
/>

Schema validation

To validate with a schema instead of rules, add a resolver. This example uses Zod:

bun add zod @hookform/resolvers
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const schema = z.object({
  email: z.email("Enter a valid email."),
  message: z.string().min(10, "Write at least 10 characters."),
});

type FormValues = z.infer<typeof schema>;

const { control, handleSubmit } = useForm<FormValues>({
  resolver: zodResolver(schema),
  defaultValues: { email: "", message: "" },
});

Field errors come from the schema messages, so the fields above stay the same.