DEV Community

Ron (teRON) Bullock
Ron (teRON) Bullock

Posted on • Originally published at teronbullock.com

React Hook Form: Why Leave useState Behind

TL;DR

The Switch:

I replace useState with React Hook Form + Zod to eliminate state fragmentation and centralize business logic.
The ROI:

Using uncontrolled inputs significantly reduces expensive re-renders in data-heavy apps, cutting down on technical debt and maintenance costs.

The Result:

A high-performance, type-safe, and accessible form architecture that prioritizes product stability over manual state management.

Intro
Forms are consistently one of the highest-risk areas in a product.

They are the gateway between user input and business logic. Inconsistencies in validation or state handling can become production bugs, failed submissions, and increased support overhead. In fast-moving teams, scattered form state can actually slow delivery down. By lowering this friction, teams can increase ROI and decrease maintenance costs.

The Problem: State Fragmentation

In many React codebases, form complexity grows incrementally over time.

As more fields get added, simple validation becomes conditional and asynchronous. Spreading form logic across multiple useState hooks and effects creates dependencies that are hard to manage. The key issue becomes code predictability before speed or performance becomes the primary concern.

The Strategy: Schema-Driven Integrity

I pair React Hook Form with Zod for schema-based validation, shifting logic away from scattered component state into a centralized schema: a single source of truth.

This choice allows me to focus on a single location when business needs change. If a field becomes mandatory or a validation pattern gets stricter, it all gets updated in one place. The UI remains perfectly in sync without hunting down fragmented logic across multiple components.

Because React Hook Form uses uncontrolled inputs, I can bypass expensive re-rendering cycles on every keystroke. This is a huge performance boost. While the impact is small on a login form, this optimization is critical for complex, data-heavy interfaces that modern products require to stay snappy and responsive.

Tradeoffs and Constraints

This approach is not universal.

  • For highly custom, fully controlled UI components, integrating with uncontrolled inputs can require additional adapters or wrappers.
  • Debugging can be less intuitive compared to fully controlled forms, especially for teams unfamiliar with the uncontrolled component pattern.
  • In very small forms, the added abstraction may not always justify the initial setup overhead.
  • While this approach reduces long-term maintenance, it requires developers who are familiar with these architectural patterns.

How to Implement React Hook Form and Zod?

To build a robust data layer, I integrate React Hook Forms with Zod via a resolver (a bridge that translates Zod validation into React Hook Form errors). This allows me to offload complex validation logic to a dedicated schema while maintaining full TypeScript support.

1. Install the packages

Install the core library along with the standard Zod resolver. Note that Zod’s type inference works best when strict: true is enabled in your tsconfig.json to ensure type safety remains airtight.

# Install all 3 packages React Hook Form, the resolvers and Zod
npm install react-hook-form @hookform/resolvers zod
Enter fullscreen mode Exit fullscreen mode

2. Create your form

I follow a layered architecture by separating form logic from the presentation. By encapsulating React Hook Form’s logic within a custom hook, the UI components stay focused purely on rendering.

This separation makes the logic easier to unit test independently and ensures the UI remains easy to maintain as the product evolves.

Create a custom hook file, I named mine “useDemoForm”. Now, import it into your form component. The hook’s return values will slot in as it’s built it out.

import { useDemoForm } from '...';

export const DemoForm = () => {
const { } = useDemoForm();

return (
<Card className='w-100 flex flex-col p-4'>
<form onSubmit={}>
// ...add your input code
</form>
</Card>
);
};
import { useDemoForm } from '...';

export const DemoForm = () => {
  const { } = useDemoForm();

  return (
    <Card className='w-100 flex flex-col p-4'>
      <form onSubmit={}>
       // ...add your input code
      </form>
    </Card>
  );
};
Enter fullscreen mode Exit fullscreen mode

3. Handle React Hook Form’s Logic in a Custom Hook

Create a new file for your custom hook. This is where the core logic lives. Keeping the logic out of the UI component allows the form component to stay focused on rendering only.

Key concepts:

  • FormValues is your interface that defines the shape of your form data. Zod will validate against this, and TypeScript will enforce it.
  • zodResolver is what connects your Zod schema to RHF’s validation system.
  • Return an object rather than an array. Once you’re returning four or more values, named destructuring is significantly cleaner and maintainable than destructuring by position.
import { useForm, type SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

interface FormValues {
example: string;
}

export const useDemoForm = () => {

// React Hook Form methods.
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(demoFormSchema)
})

// The onSubmit function handler.
const onSubmit: SubmitHandler<FormValues> = (data) => {
console.log(data);
}

// Watch input value by passing the name of it.
console.log(watch("example"))

// Return values.
return {
register,
handleSubmit,
errors,
onSubmit
}
}
import { useForm, type SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

interface FormValues {
  example: string;
}

export const useDemoForm = () => {

  // React Hook Form methods.
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(demoFormSchema)
  })

  // The onSubmit function handler.
  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data);
  }

    // Watch input value by passing the name of it.
  console.log(watch("example")) 

  // Return values.
  return {
   register,
   handleSubmit,
   errors,
   onSubmit
  }
}
Enter fullscreen mode Exit fullscreen mode

register:
Connects an input to RHF so it can track its value and validation state.

handleSubmit:
Wraps your onSubmit function, runs validation first, and only calls your handler if the form is valid.

formState:{ errors }:
The object RHF uses to surface validation errors per field.

watch:
Lets you read a field’s current value reactively. Useful for conditional logic, not needed on every form.

useForm:
A custom hook for managing the form. It takes one optional object as its argument.

Read more about useForm.

onSubmit:
This is the function that handles your validated data. It’s wrapped by handleSubmit, so you’re guaranteed that the data object perfectly matches your Zod schema.

Step 4: Ensuring Accessibility (A11y)

Now let’s bring everything together in the UI. By using the register function and the errors object, this ensures the form is accessible to all users, including those using screen readers.

The register function handles the input’s name, ref, and event listeners. Use the spread operator (…) to apply these to our input elements. For accessibility standards, use aria-invalid to programmatically signal an error and aria-describedby to link the input to its specific error message.

React Hook Form automatically handles focus management. If a user submits an invalid form, the browser will immediately focus the first field that failed validation.

import { useDemoForm } from './useDemoForm';

export const DemoForm = () => {
const { register, handleSubmit, errors, onSubmit } = useDemoForm();

return (
<Card className='w-full max-w-md p-6'>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="example" className="block text-sm font-medium">
Example Field
</label>

<input
{...register("example")}
id="example"
aria-invalid={errors.example ? "true" : "false"}
aria-describedby={errors.example ? "example-error" : undefined}
className="border p-2 w-full rounded"
/>

{errors.example && (
<p id="example-error" className="text-red-500 text-xs mt-1">
{errors.example.message}
</p>
)}
</div>

<button type="submit" className="bg-blue-600 text-white p-2 rounded">
Submit
</button>
</form>
</Card>
);
};
import { useDemoForm } from './useDemoForm';

export const DemoForm = () => {
  const { register, handleSubmit, errors, onSubmit } = useDemoForm();

  return (
    <Card className='w-full max-w-md p-6'>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <label htmlFor="example" className="block text-sm font-medium">
            Example Field
          </label>

          <input
            {...register("example")}
            id="example"
            aria-invalid={errors.example ? "true" : "false"}
            aria-describedby={errors.example ? "example-error" : undefined}
            className="border p-2 w-full rounded"
          />

          {errors.example && (
            <p id="example-error" className="text-red-500 text-xs mt-1">
              {errors.example.message}
            </p>
          )}
        </div>

        <button type="submit" className="bg-blue-600 text-white p-2 rounded">
          Submit
        </button>
      </form>
    </Card>
  );
};
Enter fullscreen mode Exit fullscreen mode

register(“field”):
Injects the necessary props (onChange, onBlur, name, ref) into the input so React Hook Form can track the state without manual useState boilerplate.

aria-invalid:
A boolean attribute that tells assistive technology whether the current value of the field passes validation.

aria-describedby:
Connects the input to the specific id of the error message. This ensures a screen reader reads the error message as soon as the user focuses on the invalid field.

This approach allows you to create a form that is high-performant, easy to maintain, and inclusive for every user.

Closing Thoughts

Moving away from useState toward a schema-driven approach with React Hook Form and Zod is a commitment to product stability. By centralizing validation, reducing expensive re-renders, and enforcing strict type safety, so that codebases become more resilient and easy to maintain.

By choosing tools that prioritize predictability and performance, this ensures that applications and teams remain agile.

Top comments (0)