Jay's blog

React Hook Form: A Leaky Abstraction

I have used a handful of helper libraries for wrangling form state in React over the course of my career. In my current job, we're using React Hook Form1, specifically v6, although I think my story might still apply to those using the newer v7 branch. I enjoy working with it for the most part. I've found it to be a lighter-weight alternative to Formik.

However, this past week I ran into a weird edge case. To set the stage, a friend and colleague2 was working on a ticket to add a field to an existing form that's powered by React Hook Form. The new field was a <select> style dropdown menu with dynamically populated options based on the user's configuration. This field is required. However, most users will probably only have one option. In that case, we chose not to show this new field at all. Instead, that field will automatically default to the only option available to that user.

Here's a tiny example:

const { groups } = useUser();

const userHasMultipleGroups = groups.length > 1;

const defaultValues = {
  name: '',
  group: userHasMultipleGroups ? '' : groups[0].value,
};

const { control, handleSubmit, formState: { errors } } = useForm({
  defaultValues,
  resolver: yupResolver(schema),
});

// ...snip...

<Controller
  as={<TextInput />}
  control={control}
  name="name"
/>

{userHasMultipleGroups && (
  <Controller
    as={<SelectInput />}
    control={control}
    name="group"
    options={groups}
  />
)}

Let me break this down in words briefly to make sure we're on the same page.

This component checks a user's groups, which is provided by a custom hook called useUser.

The form's default value for group is a blank string if the given user has multiple groups available to them. If the user has just one group, which is the most common case, their group's value is automatically the default value for that form input.

Later, we choose to only render the group field if there are multiple choices to choose from.

In practice, this won't work. The form works as expected when the user belongs to multiple groups. When there's only one group available, this will fail. On submit, the validation will fail, claiming there's no value for the "group" field.

What gives? We set the default value for that field! Making things even more confusing, if we add a logging statement like console.log(getValues()), it will log an empty object until we start interacting with the form. It's like the default values aren't being used at all.

After futzing around for a couple of hours and getting nowhere, here's the result.

  1. getValues() doesn't know anything about defaultValues.
  2. The values in defaultValues won't propagate into the form's values unless the form actually renders an input.
  3. Similarly, using setValue() in a useEffect hook, for example, won't work for any field in the form that doesn't have an actual input rendered on the page.

This is actually mentioned in the documentation, but it's not exactly front and center.

-- React Hook Form docs for useForm, specifically under defaultValues

This little tidbit is what tipped me off to this situation.3

There's also a clue in the FAQs for v7 in the answer for the question, "How to work with modal or tab forms?"

It's important to understand React Hook Form embraces native form behavior by storing input state inside each input (except custom register at useEffect). One of the common misconceptions is that when working with modal or tab forms, by mounting and unmounting form/inputs that inputs state will remain. That is incorrect. Instead, the correct solution would be to build a new form for your form inside each modal or tab and capture your submission data in local or global state and then do something with the combined data.

-- React Hook Form FAQs, "How to work with modal or tab forms?"

These bits of information, pieced together, answer the question of why every piece of data in a React Hook Form-based form must have an input associated with it, even if it's a hidden input.

A Leaky Abstraction

I don't use the term "leaky abstraction" lightly because it has become a generic insult thrown around by developers online, lodged against any tool or library they don't like. However, this is a textbook leaky abstraction in my mind.

The Abstraction

I'm using React Hook Form to reduce the boilerplate of form state management. I could write my own state management for any given form using only React built-ins like useReducer or I guess a dozen or so useState instances. But it's tedious, repetitive, and error-prone. If I choose to build this using React built-ins, the state of the form and the form inputs are completely separated from each other. If I should choose to conditionally render any of my input elements, the state representing the data of those inputs is unaffected. The data and its visual representation aren't dependent on one another.4

I would assume when I'm using a form state management library, it's doing this same stuff for me behind the scenes. It's just handling all of that tedious boilerplate code for me, but I assume the semantics, like a separation between data and view, would remain the same.

The Leak

As we saw, my assumptions were incorrect. As the docs said, React Hook Form depends intimately upon the input elements on the page to hold its state. This is an implementation detail of the library that you generally don't need to know or care about... until you do.

I'm willing to believe that my form is the edge case here. Most forms on the web can be built using React Hook Form and you'll never need to know about these implementation details. Most forms probably show all inputs every render. But we were improving UX so that users don't need to care about a required dropdown input if there's only one option to select from.

I want to make it clear here that I'm not calling React Hook Form a leaky abstraction as an insult. I still mostly like the library, even if it's not built the same way I would choose to build it. I'm going to continue using it. I still prefer it to Formik. But this is an implementation detail, or perhaps a consequential design philosophy, that users should be aware of.


  1. Because I regularly confuse the name as react-form-hook, I've given up and lovingly refer to it as react-man-door-hand-hook-car-door instead. I'm sure my coworkers have become tired of this joke.

  2. This is the second shout out to Emily on my blog! ❤️

  3. Note the docs' mention of combining the default data at the time of submission of the form. I did consider this approach after I'd already implemented an input component that intelligently used a hidden input when it made sense. But this approach wouldn't have worked in my case because I'm using schema validation to validate the form. Before I even got the chance to inject my default value, the validation would have failed and my submit function wouldn't have been called. I could have moved validation into my submit function, but then I'd lose the convenience of React Hook Form's built-in error messages. I guess I could make two versions of the schema, but who knows what unexpected gotchas that would uncover? It's a house of cards situation, and using a hidden input was the best choice.

  4. It's been a couple of years since I've used Formik, but this is roughly how it operates IIRC. When I switched to working on a codebase using React Hook Form, I assumed this was still the case.

#react