I used to dread the ticket. You know the one. “Add a multi-step registration wizard.” Or even just “Update the profile settings page.”
For years, handling forms in React felt like a punishment. I started with Redux Form (dark times), moved to Formik (better, but still verbose), and eventually just started writing my own massive state objects because I didn’t trust libraries anymore. It was always the same headache: managing loading states, handling validation logic that grew into spaghetti code, and watching my entire app re-render every time I typed a single character into an input field.
Then I actually sat down and learned React Hook Form.
I know, I’m late to the party. But if you’re still clinging to your old onChange handlers or wrestling with heavy form libraries in 2026, we need to talk. Combining React Hook Form (RHF) with a component library like Chakra UI isn’t just “another way” to do it. It’s the only way I can build forms now without losing my mind.
Why I Finally Switched
The problem with most React form approaches is that they rely too heavily on controlled components. You type “a”, state updates, React re-renders. You type “b”, state updates, React re-renders. If you have a complex form with expensive validation logic running on every render, your UI starts to lag. It feels gross.
React Hook Form flips this. It leans on uncontrolled components by default, registering refs to the inputs. It only triggers re-renders when it absolutely has to (like when an error message needs to pop up). The performance difference isn’t just theoretical metrics; you can feel it when you type.
But here’s the kicker: hooking it up to UI libraries like Chakra used to be tricky. Now? It’s shockingly easy.
The Setup: RHF + Chakra UI + Zod
I don’t write validation logic inside components anymore. It’s messy and hard to test. I use Zod. If you aren’t using Zod yet, stop reading this and go look it up. Seriously.
Here is my go-to stack for basically every project I’ve touched in the last six months:
- React Hook Form for the engine.
- Chakra UI for the pretty inputs and accessibility.
- Zod for the schema validation.
Let’s build a login form. Nothing fancy, just email and password. But we’re going to do it right, with proper error handling and types.
import { useForm } from "react-hook-form";
import {
Box,
Button,
FormControl,
FormLabel,
Input,
FormErrorMessage,
VStack
} from "@chakra-ui/react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
// 1. Define your schema.
// This is the source of truth. If it's not here, it doesn't exist.
const loginSchema = z.object({
email: z.string().email("That doesn't look like an email"),
password: z.string().min(8, "Password needs at least 8 characters"),
});
// Infer the TypeScript type from the schema automatically
type LoginValues = z.infer<typeof loginSchema>;
export const LoginForm = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginValues) => {
// Simulate an API call
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("Form Data:", data);
};
return (
<Box p={8} maxW="400px" borderWidth={1} borderRadius="lg" boxShadow="lg">
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={4}>
<!-- Email Field -->
<FormControl isInvalid={!!errors.email}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
placeholder="you@example.com"
{...register("email")}
/>
<FormErrorMessage>
{errors.email?.message}
</FormErrorMessage>
</FormControl>
<!-- Password Field -->
<FormControl isInvalid={!!errors.password}>
<FormLabel htmlFor="password">Password</FormLabel>
<Input
id="password"
type="password"
{...register("password")}
/>
<FormErrorMessage>
{errors.password?.message}
</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="teal"
width="full"
isLoading={isSubmitting}
>
Sign In
</Button>
</VStack>
</form>
</Box>
);
};
The “Spread” Magic
Look at that {...register("email")} line. That is doing so much heavy lifting. In the old days (or with other libraries), you’d have to manually wire up value={values.email}, onChange={handleChange}, and onBlur={handleBlur}. It was tedious and prone to typos.
The spread operator injects the necessary refs and event handlers directly into Chakra’s Input component. Chakra is smart enough to accept these props without complaining. It just works. The FormControl component handles the layout, and isInvalid turns the border red automatically when RHF detects an error. It’s clean.
When Simple Isn’t Enough: The Controller
Of course, nothing is ever that simple. Eventually, you run into a component that doesn’t expose a native HTML input ref. Maybe it’s a complex third-party DatePicker, or a Chakra Switch or Slider.
I hit a wall with this last week. I tried spreading register onto a custom Select component and it just… ignored me. No errors, no data. Just silence.
This is where the Controller component saves your bacon. It acts as a wrapper that bridges the gap between RHF’s state management and components that need to be controlled.
import { Controller } from "react-hook-form";
import { Switch, FormControl, FormLabel } from "@chakra-ui/react";
// Inside your form component...
<Controller
name="notifications"
control={control} // You get this from useForm()
defaultValue={false}
render={({ field: { onChange, value, ref } }) => (
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="notifications" mb="0">
Enable Notifications?
</FormLabel>
<Switch
id="notifications"
isChecked={value}
onChange={onChange}
ref={ref}
/>
</FormControl>
)}
/>
Is it a bit more code? Yeah. But it gives you explicit control over how the value is passed in and how changes are reported back. You aren’t fighting the library; you’re just telling it exactly what to do.
Handling Errors Without the Spaghetti
The thing I love most about this setup is how declarative the error handling is. In my Formik days, I often found myself writing conditional logic inside the render function to decide when to show an error. “Show if touched AND error exists, unless submitting…”
With Chakra’s FormControl and RHF’s formState, that logic is standardized. You pass isInvalid={!!errors.fieldName} to the parent. Then you drop a FormErrorMessage component in there. If the error exists, it renders. If not, it doesn’t. You don’t have to write ternary operators all over your JSX.
A Note on “handleSubmit”
One small thing that tripped me up at first: handleSubmit is a higher-order function. You pass your actual submit logic into it. This is brilliant because RHF runs the validation before your function ever gets called.
If the user tries to submit a blank form, your onSubmit function never runs. You don’t need to add if (!isValid) return at the top of your handler. RHF acts as the bouncer. If the data isn’t clean, it doesn’t get in.
Why This Matters
We spend so much time building forms. Login, signup, settings, checkout, contact us. If your form stack is annoying to work with, you’re going to write bad forms. You’ll skip validation because “it’s too hard to add.” You’ll ignore accessibility because “the custom input broke the aria tags.”
By pairing React Hook Form with Chakra UI, you remove the friction. The types are safe (thanks Zod), the UI is consistent (thanks Chakra), and the state management doesn’t make your browser fan spin up (thanks RHF).
I’m never going back to writing manual onChange handlers. Life is too short.











