React Forms Tutorial for Beginners

Doing forms right in React is difficult. This post is an exploration of some "best practices" when using forms in React.

Last UpdatedApril 3, 2024
Estimated Read Time11 minutes

Why this post?

The other day, I was reading this discussion about handling async, default form values in React Hook Form and it occurred to me that 1) present day, there are a lot of conflicting opinions and "best practices" when dealing with forms in React, and 2) for a long time I struggled with forms and it was about time to organize my thoughts.

This post is an exploration of "best practices" when dealing with React forms. By no means does it cover everything nor am I an expert in form design. These are just a collection of my notes after years of struggling to build stable and predictable forms in React.

Prerequisites

This is an intermediate level post. You should:

Controlled vs. Uncontrolled forms

When dealing with forms in React, it is critical to understand "controlled" vs. "uncontrolled" forms and inputs. Here's how I like to remember it:

  • Uncontrolled - let the browser keep track of form values
  • Controlled - let React keep track of form values as state

Here is an uncontrolled form:

function UncontrolledForm() {
  return (
    <form action="/some-endpoint" method="post">
      <input name="exampleInput" defaultValue={2} min={2} required />
      <button>Submit</button>
    </form>
  );
}

When you click "Submit", the browser will wrangle all the form values, validate that the input is 1) filled out and 2) greater or equal to a value of 2, and submit to your action endpoint via POST request.

But in many cases, you want more control over the form than this.

Here is the same form as above, but as a controlled form:

function parseForm(form: HTMLFormElement) {
  // parse, validate, and return form data
}

function ControlledForm() {
  const [form, setForm] = useState({ exampleInput: 2 });

  function handleSubmit(event) {
    event.preventDefault();

    const data = parseForm(event.target);

    fetch("/some-endpoint", { method: "post", data });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="exampleInput"
        value={form.exampleInput}
        onChange={(e) =>
          setForm((prev) => ({ ...prev, exampleInput: e.target.value }))
        }
        min={2}
        required
      />
      <button>Submit</button>
    </form>
  );
}

There are a couple of very important implications to a controlled form in React.

  1. defaultValue does nothing when input is controlled
  2. value and onChange must both be implemented and stored in a React state variable
  3. You should never set value to null or undefined. This will confuse React into thinking you are using an uncontrolled input.

defaultValue doesn't do anything with Controlled forms

If you read my post on React component lifecycle, you know that on the first render of any component, the state variable is initialized:

const [form, setForm] = useState({ exampleInput: 2 });

In our example from above, on the first render, the value of form will always be { exampleInput: 2 }, and therefore, our input element will have a value of 2. In other words, even if we tried to set defaultValue, it would never apply because value will always be populated according to useState default value.

Input value should not be set to undefined in Controlled form

Do not do this:

<input name="exampleInput" value={undefined} />

Or equivalent in an actual form example:

const [exampleInput, setExampleInput] = useState();
// const [exampleInput, setExampleInput] = useState(undefined); <--- or this!

<input name="exampleInput" value={exampleInput} onChange={setExampleInput} />;

If you do this, React will think this is an uncontrolled input and initialize it as such. Once you start typing into the input, you'll get this error:

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.

The value prop of an input must remain a string through its lifetime for React to properly handle it as a controlled input. Furthermore, as the error suggests, a form input cannot change from a controlled => uncontrolled or vice-versa during its lifetime.

Input value should not be set to null in Controlled form

Do not do this:

const [exampleInput, setExampleInput] = useState(null);

<input name="exampleInput" value={exampleInput} onChange={setExampleInput} />;

For the same reasons explained in the previous section, this will confuse React into thinking this is an uncontrolled input, when really, it is controlled. If you do this, you will get this error:

Warning: `value` prop on `input` should not be null. Consider using an empty string to clear the component or `undefined` for uncontrolled components.

How to initialize Controlled form with values from async, external API

It is a very common pattern to initialize a form with some data stored in an external data store.

  1. Load data on first page load
  2. Render form with async data as "default value"
  3. User changes data within the form

Strategy 1: Loading state, then render

Here's a relatively stable pattern for achieving this. It involves a "Data Fetcher" component that is responsible for displaying a loading state until the data arrives and then rendering the form when the data is available. Once the form renders, no additional data requests will happen and the form value will be updated by the user.

Here's a simple form:

function FormComponent({ defaultValue }: { defaultValue: string }) {
  const [title, setTitle] = useState(defaultValue);

  return (
    <form>
      <input
        name="title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
    </form>
  );
}

And then here's the "Data Fetcher":

type Todo = {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
};

function DataFetchingComponent() {
  const [todo, setTodo] = useState<Todo>();

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/todos/1")
      .then((response) => response.json())
      .then((json) => setTodo(json));
  }, []);

  if (!todo) {
    return <p>Loading...</p>;
  }

  return <FormComponent defaultValue={todo.title} />;
}

As you can see, we never have to handle "nullish" default values because we're not rendering the form until we know data is available.

Strategy 2: Reset form when data arrives

If the first strategy doesn't meet your needs, you might have to deal with loading the data in the same component that the form is rendered. When using this strategy, our "no undefined or null form value" rule applies.

In the code below, we have a PROBLEM. Can you spot it?

function FormWithAsyncInitialValues() {
  const [title, setTitle] = useState<string>();

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/todos/1")
      .then((response) => response.json())
      .then((json: Todo) => setTitle(json.title));
  }, []);

  return (
    <form>
      <input
        name="title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
    </form>
  );
}

If you use this code, you will get the dreaded A component is changing an uncontrolled input to be controlled. Why?

Because on the first render, our async data fetching call hasn't run yet, which means title is equal to undefined. This means that we're actually setting our input's value=undefined on the first render. After the network request succeeds, title will be a string value and now our input value will be defined.

Since our first value was undefined, React initialized this form as uncontrolled but we later updated it to be controlled, which is not allowed.

To fix this, all we need to do is initialize our title as an empty string. So we'll go from this:

// BAD!!!
const [title, setTitle] = useState<string>();

To this:

// GOOD :)
const [title, setTitle] = useState<string>("");

Do I need a form library?

In a day where there's a library for just about everything, it might come as a shock that regular 'ole HTML + vanilla React is a pretty powerful combination when it comes to building forms. Here's a perfectly valid way to build a form:

import type { FormEvent } from "react";

export function MyForm() {
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();

    const form = new FormData(e.target as HTMLFormElement);
    const parsedData = Object.fromEntries(form.entries());

    // Do something with the form data
    const { jobOccupation } = parsedData;
    console.log(jobOccupation);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="jobOccupation"
        defaultValue="Software Engineer (default value)"
      />
      <button>Submit</button>
    </form>
  );
}

You can see we're using uncontrolled inputs on this form and then once the form submits, we're intercepting the submit event, parsing through the data, and doing something with it.

Need to turn this into a controlled form so you can use form values elsewhere in the component? You can do that without a library too:

export function MyForm() {
  const [form, setForm] = useState({
    jobOccupation: "Software Engineer (default value)",
    age: "50", // form stores as string, converts to number on submit
  });

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();

    const form = new FormData(e.target as HTMLFormElement);
    const parsedData = Object.fromEntries(form.entries());

    // Do something with the form data
    const { jobOccupation, age } = parsedData;
    console.log(jobOccupation); // string
    console.log(+age); // number
  }

  return (
    <div>
      {/* One benefit of a controlled form is the ability to display form values outside of the form */}
      <p>
        User is a {form.age} year old {form.jobOccupation}
      </p>
      <form onSubmit={handleSubmit}>
        <input
          name="jobOccupation"
          value={form.jobOccupation}
          onChange={(e) =>
            setForm((prev) => ({ ...prev, jobOccupation: e.target.value }))
          }
        />

        <input
          name="age"
          value={form.age}
          onChange={(e) =>
            setForm((prev) => ({ ...prev, age: e.target.value }))
          }
        />
      </form>
    </div>
  );
}

Above, I showed an uncontrolled and controlled form implemented with basic React state. So why would you need a library?

As your form grows in size and complexity (especially dynamically constructed forms), handling the form's global "state" in a single state variable becomes difficult. Furthermore, you'll run into challenges of...

  • Validating inputs as they change and displaying an error message close to the affected input
  • Optimizing performance of the form and avoiding too many re-renders (in our example above, any change to any input re-renders the entire form component)
  • Integrating custom input components from external libraries like Material UI
  • Handling "touched" and "dirty" form states (i.e. if the user has already typed into an input, we might want to handle that differently than if they haven't touched it)
  • If you're using TypeScript, having a fully "typesafe" form becomes difficult as the form grows

For any semi-complex form, you will run into these problems.

When and Why I use React Hook Form

Ideally, we'd have the best of both worlds. For simple forms, we don't need the overhead of a form library, and for complex ones, we could bring in a library.

This is the reason I use react-hook-form for all but the simplest of forms. React Hook Form (RHF) uses a simple React hook called useForm that allows for native form HTML while also providing the ability to control inputs, validate inputs, and parse form data on submission.

In my opinion, this is the way to go in almost every scenario. With that said, let's jump into some more complex topics!

Using React Hook Form

Now that we have a solid understanding of how React itself handles forms and the difference between controlled and uncontrolled, React Hook Form (RHF) will make a lot more sense.

Under the hood, RHF embraces uncontrolled inputs which allows you to leverage all the native browser form APIs while also getting the benefit of all the library has to offer.

In the case you need to control an input (e.g. you're integrating a custom input component), RHF also provides native support for that via the Controller component.

The best part is that we can mix controlled and uncontrolled inputs in the same form without any change in behavior!

This post is not meant to be documentation, but rather a catalogue of patterns that I've used with RHF. The following sections are some of those patterns.

React Query + React Hook Form + Zod Validation

React Query + RHF + Zod (and TypeScript) is my current go-to setup for building forms in React. Below is a basic form example that wires all of these libraries up to work together. While the code is self-explanatory, there are a few things I want to highlight.

  • Validation - there are three levels of validation that can happen with forms, and while you probably don't need all three, they each provide a slightly different assurance.
    • Input level validation - This level of validation is great for validating an input and immediately showing a descriptive error message to the user to notify them that the form is invalid.
    • Form submission validation - This level of validation (using Zod or another validation library) becomes more and more useful as your forms grow in size. More specifically, it becomes useful when your form has conditional fields that you are registering/unregistering based on certain conditions because handling this sort of logic at the field level is complicated and usually not worth the effort. It is also useful if you want to add some more complex field validation that is not shown to the user.
    • Backend validation - This is not shown in the example, but the API you submit your data to will often perform some validation either at the request level or database level. If you have already validated the data on the frontend, this might seem useless, but backend validation will provide you with the assurance that someone hasn't intentionally modified the POST request sent from the form.
  • Async default values - In the example, you can see that we're not rendering the form until we have our default values populated. While RHF does allow you to pass an async function to defaultValues, I find it more stable to load the data and then render the form to avoid the complexity described here.
import "./styles.css";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
  useMutation,
} from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const queryClient = new QueryClient();

type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

// Zod schema for form values
const FormSchema = z.object({
  title: z.string(),
  body: z.string(),
});

// { title: string; body: string; }
type FormData = z.infer<typeof FormSchema>;

type FormProps = {
  defaultValues: FormData;
  onSubmit(data: FormData): void;
  isSubmitting: boolean;
};

function FormComponent({ onSubmit, defaultValues, isSubmitting }: FormProps) {
  const { register, handleSubmit } = useForm<FormData>({
    defaultValues,
    resolver: zodResolver(FormSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("title", { required: true })} />
      <textarea {...register("body")} />
      <button
        disabled={isSubmitting} // Prevents double-submit
      >
        Submit
      </button>
    </form>
  );
}

function DataFetcher() {
  const query = useQuery<Post>(["posts"], () => {
    return fetch("https://jsonplaceholder.typicode.com/posts/1")
      .then((response) => response.json())
      .then((json) => json);
  });

  const mutation = useMutation((data: FormData) => {
    return fetch("https://jsonplaceholder.typicode.com/posts", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(data),
    });
  });

  if (query.isError) {
    return <p>Error, could not load form.</p>;
  }

  if (query.isLoading) {
    return <p>Loading...</p>;
  }

  // Since we have checked loading & error states, we know data is valid
  const { title, body } = query.data;

  return (
    <FormComponent
      defaultValues={{ title, body }}
      onSubmit={async (data) => {
        await mutation.mutateAsync(data);
        alert("Form submitted successfully!");
      }}
      isSubmitting={mutation.isLoading}
    />
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <DataFetcher />
      </div>
    </QueryClientProvider>
  );
}