DEV Community

Michael Warren
Michael Warren

Posted on • Originally published at michaelwarren.dev

You probably don‘t need controlled forms

I‘m going to start off by saying that this article is aimed mostly at React developers and applications because React is where I have seen this pattern the most. I‘m sure that the other frameworks provide mechanisms where controlled forms can be implemented, but React is the one that I feel led the way in this pattern the most, starting well before their new docs site was first published in 2023.

I don‘t intend this article to shame or blame anyone for the patterns they chose. My purpose is to inspire a little rethinking about forms you probably have in your applications today and maybe save you a little (or a lot) of code in your client-side bundle with a different idea.

What is a "controlled" form?

The most succinct way to describe a controlled form is: a form in which either the form elements‘ values and/or their validation are controlled by the application state instead of by their own internal state. An “uncontrolled” form or field is one where the value/validity comes from its own internal state and not by application/component state.

A practical example of a “controlled” form might be the one below.

// some JSX react component
const [someState, setSomeState] = useState('');

return <form>
  <label for="input">some form element</label>
  <input
    id="input"
    value={someState}
    onInput={(e) => setSomeState(e.target.value)}
  >
</form>
Enter fullscreen mode Exit fullscreen mode

I‘ve seen code like this a ton. A form element whose value property (it actually matters in this case that its the property and not the value attribute) is bound to the value of some component state and an event handler on the input that sets that same state. When the user types in the input, the input event fires, the component state is updated, and the component is re-rendered which renders (sometimes reuses) the input with the new value. In a pure controlled world, the user might not see the character they typed until the re-render cycle has completed.

The value in this approach is that your component state (or global state also I suppose) is the source of truth and your UI is a function of your state. React folks love that formula: UI = f(state).

React used to be heavily invested in this pattern, as shown by the fact that every example on the legacy Forms documentation page was a controlled input example just like the one above. I believe that this page full of controlled examples set a large amount of web developers down the path of thinking that extra JS libraries were needed for ALL forms instead of perhaps just for complex forms. But, as always, the web has changed and even if the "you need a library for that" concept used to be true, its certainly much less true these days.

Values vs validation

I think it is worth another few paragraphs of context setting to talk about the difference between an input's value and its validity when it comes to being “controlled” and “uncontrolled”. Technically it is possible to separate the value from validity in terms of the source of truth, though most applications I have seen tend to use the same approach for validity as they do for setting values.

So the example from above isn‘t really complete because its missing field-level validation that we all need when building our UIs. The more correct the data we send to our backends, the better, right!?

Controlled validation operates the same way controlled value setting does except that the application state is setting an error state/message instead of a value.

Controlled validation is where most of my heartburn with controlled forms comes from. Validation libraries are huge and not always necessary, but are assumed to be the default way to deal with forms.

For a more complete validation example, I‘ll turn to a library like the legacy React docs suggested I do with the admittedly small section in the legacy docs about validation.

I‘ll use the example from the homepage of react-hook-form.

import { useForm } from "react-hook-form"

export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm()

  const onSubmit = (data) => console.log(data)

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form onSubmit={handleSubmit(onSubmit)}>
      <label for="input">some form element</label>
      {/* include validation with required or other standard HTML validation rules */}
      <input id="input" {...register("exampleRequired", { required: true })} />
      {/* errors will return when field validation fails  */}
      {errors.exampleRequired && <span>This field is required</span>}

      <button type="submit">Submit</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

This example shows using a 40 kB (14.7 kB gzip) library to ensure that an input is required, has an error message and the form won‘t submit if the input doesn’t have a value. Guess what? Browsers do that for you for free nowadays. In fact, the browser would have calculated the input to be invalid before the event listeners upon which react-hook-form depends even ran.

So when don‘t you need controlled forms?

Ok, you might need controlled forms sometimes. But I would venture a guess that a large, large majority of the forms on your sites and apps simply gather data to send to some backend. If that is all you are doing, you probably definitely don‘t need application state at all.

Application/component state binding is only necessary if your component needs to re-render for reasons OTHER than the form data.

The implication of the examples in this article so far are that you need to wire up event listeners to your form elements to update your component/application state in response to the user typing. But notice that in all of these examples, there‘s nothing else going on in the component that would require re-rendering it. That component state we‘re taking great care to initialize and update is only being used for the form itself. If that is all your component is doing, then the entire event emit cycle and state update is completely unnecessary.

Enter FormData

Let me introduce you to the built-in FormData object that operates exactly like your component state except the browser keeps it updated completely automatically without the need for excess component state and re-renders. Let‘s look at how you could take the react-hook-form example in this article and convert it to use FormData for values and browser default validation.

export default function App() {

  const onSubmit = (event) => {
    // the event target is the form itself
    const data = new FormData(event.target);

    // this is the "send the data to some API" part
    console.log(Object.fromEntries(data));
  }

  return (
    <form onSubmit={onSubmit}>
      <label for="input">some form element</label>
      {/* preventing the `invalid` event turns off the browser default validation message because we have our own */}
      <input id="input" name="some-name" required @onInvalid={(e) => e.preventDefault()}/>
      <span className="error-message">This field is required</span>

      <button>Submit</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode
.error-message {
  /* hidden by default */
  display: none;
  color: red;
}

form:has(:user-invalid) .error-message {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Tell me what you think about this approach

And there we go. No libraries, no unnecessary useState and component re-render. The browser tracks both the value of the input, and its validity state automatically. There‘s no need to sync the form state with our component state because there's nothing else in this component except our form. The form will not submit when the input doesn’t have a value because we used the browser default required attribute. The :user-invalid css style is applied when the user interaction causes an invalid state (you could use :invalid as well if that is a better fit). If we had multiple fields with multiple errors, our :has selector on the form would show them individually when the user tabs through, or all at once when the user tries to submit. We have all the same functionality of the react-hook-form example, with half the code.

A word about browser constraint validation

What you don‘t see in my example is some of the magic that the browser has built-in for you. That is the Constraint validation API that is making sure that the form won‘t submit with invalid fields. There are more validation attributes than just required and they represent practically all of the simple validation that could be done on a form field of practically any type.

Each HTML form element implicitly has a validity property that holds a ValidityState object that tells you the validity of the field and which of the constraints are invalid if any. If you ever want to check the validity of a field, or cause a form to do validation, you can call the checkValidity yourself anytime to programmatically make errors appear or to check the form state yourself.

In fact, some of the better validation libraries are built on the native Constraint Validation API, so if you get to a place where you need to pull in a library, double check that the authors aren’t rolling their own validation. If they aren’t using the built-in validation, they are probably wasting code.

So when would you need a controlled form?

I admit that my examples are the most basic form possible and that there are plenty of complex apps out there. And I also admit that part of my writing this article is to make folks reconsider how they write forms in an effort to simplify them. However, you may still need to sync an input‘s value with your component state if there is some other part of your component that would need to change based on that state. Some examples of when it might make sense to use events and useState are:

  • Hiding/showing parts of a form based on user selection
  • Changing the values of a future field based on the selection of a previous field
  • Enabling/disabling fields conditionally
  • Making a field required conditionally

But, does that mean that your application state should be in charge of your form state the way that legacy react docs describe? I’m not so sure. All of the above examples can be done with event listeners and useState without tying the value of any field to that same state, so I’m unsure if binding the value to application state is all that necessary.

Last thoughts

I also think that the React team may have reconsidered as well, since the first example of reading data from a form on react.dev is an “uncontrolled” example. And they have written more documentation about the ramifications of “controlled” forms and the trouble they can cause.

And you can also check out this great talk by David Khourshid on his quest to get rid of useState in general. He talks at length as well about how the ”controlled” form pattern should probably be a last resort instead of the first approach.

It might be worth taking a good long look at the forms in your application and separating out simple from complex. Be mindful of whether you’re setting component state only for forms and fields or if there are other elements that depend on that state. If the form stands alone, then you can save yourself a ton of code by using browser defaults. You may indeed be able to get rid of that hefty validation library and save your user some bytes down the wire as well!

Top comments (0)