skip to content
Samuel Edwin's Website

React Hook Form Tutorial - Selects, Radio Buttons

/ 4 min read

In this post we are going to learn:

  • How to handle <select>s and <input type="radio" /> using React Hook Form.
  • How to validate choices in a safer way using Zod and TypeScript.

Form Requirements

We’re going to build a flight booking form that contains mandatory multiple choices:

  • Flight Class
    • Economy
    • Business
    • First Class
  • Payment Method
    • Credit Card
    • Bank Transfer
    • Paypal
    • Bitcoin
Form blank UI

Define the validation schema

We can use strings to store the user’s choices and will deal with the validation later.

schema.ts
import z from "zod";
export const formSchema = z.object({
flightClass: z.string(),
paymentMethod: z.string(),
});

Build the UI

We define the inputs and selects the standard way web developers use them, nothing fancy.

The only difference is we register the UI controls using {...register('fieldName')} so React Hook Form can handle the inputs and validations.

App.tsx
import { useForm } from "react-hook-form";
import { Form, formSchema } from "./schema";
import { zodResolver } from "@hookform/resolvers/zod";
export default function App() {
const {
register,
formState: { errors },
handleSubmit,
} = useForm<Form>({
resolver: zodResolver(formSchema),
});
return (
<form
onSubmit={handleSubmit((data) => {
console.log(data);
})}
>
<div>
<label>Flight Class</label>
<div>
<input type="radio" value="economy" {...register("flightClass")} />
<label>Economy</label>
</div>
<div>
<input type="radio" value="business" {...register("flightClass")} />
<label>Business</label>
</div>
<div>
<input type="radio" value="first-class" {...register("flightClass")} />
<label>First Class</label>
</div>
<div className="error">{errors.flightClass?.message}</div>
</div>
<div>
<label>Payment Method</label>
<select {...register("paymentMethod")}>
<option value="">Choose</option>
<option value="credit-card">Credit Card</option>
<option value="bank-transfer">Bank Transfer</option>
<option value="paypal">Paypal</option>
<option value="bitcoin">Bitcoin</option>
</select>
<div className="error">{errors.paymentMethod?.message}</div>
</div>
<button type="submit">Submit</button>
</form>
);
}

Let’s see what error messages will show up if we submit the form directly without filling anything.

Form with technical error message

Refining error messages

Let’s break down what happened to each field and fix them separately.

Flight Class

Whenever we see an error message like Expected X, received Y, that means the type expected input type in the schema and the actual input doesn’t match.

In this particular case it happens because the user hasn’t picked a choice and that’s why the flight class is null.

In order to fix this, we tell Zod what error message we want when we receive an invalid type.

schema.ts
export const formSchema = z.object({
flightClass: z.string({
invalid_type_error: "Please select a flight class",
}),
paymentMethod: z.string(),
});
Form user friendly flight class error message

Payment Method

Once you choose a flight class and submit the form, the Payment Method field is not validated and treated as valid.

The reason is because the default option is an empty string.

<option value="">Choose</option>

We can fix this by validating that an empty string is not a valid option using string length validation.

schema.ts
export const formSchema = z.object({
flightClass: z.string({
invalid_type_error: "Please select a flight class",
}),
paymentMethod: z.string().min(1, "Please select a payment method"),
});
Form user friendly error messages

There’s a gotcha

The above solution works but it also has a weakness. Typos can creep up into the app because any string is accepted.

To give you an example, let’s change one of the radio button values to something else.

<input type="radio" value="first-class" {...register("flightClass")} />
<input type="radio" value="invalid-value" {...register("flightClass")} />

Submit the form and it will happily accept the value.

Terminal window
Object {
flightClass: "invalid-value",
paymentMethod: "bank-transfer"
}

If you have a logic that depends on the user choosing first-class as their choice, then this change would break your app.

We can fix this by limiting the acceptable strings that the fields can receive.

Limiting possible inputs

Zod.union and Zod.literal works perfectly to handle this case.

schema.ts
export const formSchema = z.object({
flightClass: z.string({
invalid_type_error: "Please select a flight class",
}),
flightClass: z.union([z.literal("economy"), z.literal("business"), z.literal("first-class")]),
paymentMethod: z.string().min(1, "Please select a payment method"),
paymentMethod: z.union([
z.literal("credit-card"),
z.literal("bank-transfer"),
z.literal("paypal"),
z.literal("bitcoin"),
]),
});

What this means is the flightClass field can only accept one these strings: economy, business, or first-class.

The only strings accepted in paymentMethod are credit-card, bank-transfer, paypal or bitcoin.

If the First Class option contains an invalid value, here’s what it’ll look like.

Form using Zod union with bad error message

Refining union error messages

Let’s refine the error messages again.

schema.ts
export const formSchema = z.object({
flightClass: z.union([
z.literal("economy", {
errorMap: () => {
return { message: "Please select a fight class" };
},
}),
z.literal("business"),
z.literal("first-class"),
]),
paymentMethod: z.union([
z.literal("credit-card", {
errorMap: () => {
return { message: "Please select a payment method" };
},
}),
z.literal("bank-transfer"),
z.literal("paypal"),
z.literal("bitcoin"),
]),
});

Note: We define the error messages using errorMap and we define them inside the first z.literal of the union.

I know feels weird but that’s the only working solution I’ve found so far.

Now the error messages should be back to the user friendly version.

Form using zod union with user friendly error messages

Conclusion

  • Radio buttons and selects are simple to handle using React Hook Form’s {...register('fieldName')}.
  • Strings can work to store choices but it’s not the safest option.
  • Zod’s union and literal works better to store choices, limiting possible options and preventing typos.
  • When the default error message is Expected X, received Y, use invalid_type_error inside the field to customize it.

Here’s the sandbox for you to try yourself.