DEV Community 👩‍💻👨‍💻

Cover image for React Hook Form: a unique implementation
Romain Trotard
Romain Trotard

Posted on

React Hook Form: a unique implementation

Forms are used everyday, to login/signup, fill information when ordering something, ... It is really a masterpiece of a site.

I started making form in React with Redux Form which uses Redux to store information about forms. Yep, it was the old time where we were using Redux for everything.

Nowadays, things have changed. We have multiple libraries: Formik, React Final Form, React Hook Form, ... that most of the time uses React state to store information.

I know that some framework like Remix, encourages us to use pure html to make forms. But often, we have to use a client library if we want a nice user experience with quick feedback, or when you want complex validations on fields depending to each others.

React hook form is a library focusing on performance. Looking at its implementation is really interesting to learn some pattern that can can be used in other cases.
Let's look at what makes it unique compared to other form libraries implementations.


Prerequisites

Before starting to talk about implementation, I want to define some terms to be all on the same page:

  • Field: the element that collects the data from the user (input, select, datepicket, ...).
  • Field name: the identifier of the field.
  • Field value: the value filled by the user.

What would be a "simple" implementation?

If today, I have to make a form implementation. Instinctively, I would make one like Formik or React Final Form using state:

function MyForm() {
  const [values, setValues] = useState({
    firstname: "",
    lastname: "",
  });
  const onChange = (fieldName, fieldValue) => {
    setValues((prevValues) => ({
      ...prevValues,
      [fieldName]: fieldValue,
    }));
  };

  return (
    <form
      onSubmit={() => {
        // Do something with the form values
        // that are in the `values` variable
      }}
    >
      <label>
        Firstname
        <input
          type="text"
          name="firstname"
          value={values["firstname"]}
          onChange={(e) => onChange(e.target.value)}
        />
      </label>
      <label>
        Lastname
        <input
          type="text"
          name="lastname"
          value={values["lastname"]}
          onChange={(e) => onChange(e.target.value)}
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here. I just store the value filled by the user in a React state. And here we go.
It's a really simplified implementation. In a real life, I would probably use a reducer because I want to store more than values: validation errors, know if the form is submitting, if fields are dirty, ...

If you want to see a more realistic implementation
// I do not handle validation and form states
// but if I do I will probably use a reducer to that 
// instead of multiple states
function useForm(initialValues = {}) {
  const [values, setValues] = useState(initialValues);

  const handleSubmit = (onSubmit) => (e) => {
    e.preventDefault();

    onSubmit(values);
  };

  const register = (fieldName) => {
    return {
      value: values[fieldName],
      onChange: (event) => {
        setValues((prevValues) => ({
          ...prevValues,
          [fieldName]: fieldValue,
        }));
      },
    };
  };

  return {
    register,
    handleSubmit,
  };
}

function MyForm() {
  const { values, onChange, handleSubmit } = useForm({
    firstname: "",
    lastname: "",
  });

  return (
    <form
      onSubmit={() => {
        // Do something with the form values
        // that are in the `values` variable
      }}
    >
      <label>
        Firstname
        <input
          type="text"
          name="firstname"
          value={values["firstname"]}
          onChange={(e) => onChange(e.target.value)}
        />
      </label>
      <label>
        Lastname
        <input
          type="text"
          name="lastname"
          value={values["lastname"]}
          onChange={(e) => onChange(e.target.value)}
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

And you know what? That's not the way React Hook Form is implemented.


Key points of React Hook Form implementation

Do not use React state

The main things to know is that the library does not use React state / reducer to store the data but references.
It uses lazy initialization of React ref:

function useForm(config) {
  const formControl = useRef(undefined);

  // Lazy initialization of the React ref
  // Enter the condition only at the first render
  // (`createFormControl` returns an object
  if (formControl.current === undefined) {
    formControl.current = createFormControl(config);
  }
}
Enter fullscreen mode Exit fullscreen mode

And then in the createFormControl everything is stored in const that are mutated:

function createFormControl({ initialValues }) {
  const formValues = initialValues;

  const onChange = (fieldName, fieldValue) => {
    formValues[fieldName] = fieldValue;
  };

  return {
    onChange,
  };
}
Enter fullscreen mode Exit fullscreen mode

And now, it's blazingly fast because no more render.

Mmmm wait, no more render? How can we know when values are changing and state of form?

Let's see it.


Observer pattern

This pattern is really used in the industry: react-query, react-redux, ... uses it.

The principle is really simple but so powerful.
We have:

  • a subject: it's an object that keep track of an entity changes and notify of this change
  • observers: they listen to the entity changes by subscribing to the subject

If you want to see an implementation
function createSubject() {
  const listeners = [];

  const subscribe = (listener) => {
    // Add the listener
    listeners.push(listener);

    // Return an unsubscribe method
    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  const update = (value) => {
    for (const listener of listeners) {
      listener(value);
    }
  };

  return {
    subscribe,
    update,
  };
}
Enter fullscreen mode Exit fullscreen mode

React Hook Form has 3 subjects:

  • watch: to track changes of field values
  • array: to track changes of field array values
  • state: to track changes of the form state

And now, the useWatch hook subscribe to the watch subject and update a React state when its the field that we want to track that has changed.

And here we go our component when needed.

Wait! When I want to be notified when the form is going dirty, my component does not re-render when other state values changes. How is it possible?

That's the next key point.


Proxies / defineProperty

If you don't know what is a proxy you can read my article Proxy in JS: what the hell?.

In RHF, proxies are used to know which properties of the state are used in components.

Thanks to them, we can know which properties are listened by the component and only render it when these properties are changing.

function createProxy(formState, listenedStateProps) {
  const result = {};

  // Loop on the property which are in the form state
  for (const propertyName in formState) {
    Object.defineProperty(result, {
      get() {
        // Keep in mind that the property is listened
        listenedStateProps[propertyName] = true;

        // And returns the actual value
        return formState[propertyName];
      },
    });
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

And thanks to that and the observer pattern we can update the component when listened form state properties are changed.

// control is an object that has all the logic
// and the mutated object like `_formValues`,
// `_formState`, `_subjects`, ...
function useFormState(control) {
  // At start nothing is listened
  // In reality there are more properties
  const listenedStateProps = useRef({
    isDirty: false,
    isValid: false,
  });
  // Initialize with the current `_formState` which is
  // mutated
  const [formState, setFormState] = useState(
    control._formState
  );

  useEffect(() => {
    return control._subjects.state.subscribe(
      ([stateProp, stateValue]) => {
        // If the changed property is listened let's update
        if (listenedStateProps.current[stateProp]) {
          setState((prev) => ({
            ...prev,
            [stateProp]: stateValue,
          }));
        }
      }
    );
  }, [control._subjects]);

  return createProxy(formState, listenedStateProps);
}
Enter fullscreen mode Exit fullscreen mode

Stable event listener with no stale external data

Another strategy, is the usage of reference for values used in event listener that are memoized thanks to useCallback or used in useEffect.

Why?
Because we don't want to have stale data in our callback so we would have to add it in the dependency of useCallback. Because of that, it will create a brand new reference everytime the dependency is changing that does not make sense because being an event listener.

Note: it actually create a new reference at each render but the one returned by useCallback will be always the same.

Instead of that:

function MyComponent({ someData }) {
  // The reference of showData is not stable!
  const showData = useCallback(() => {
    console.log("The data is", someData);
  }, [someData]);

  return (
    <MemoizedButton type="button" onClick={showData}>
      Show the data, please
    </MemoizedButton>
  );
}
Enter fullscreen mode Exit fullscreen mode

We have that:

function MyComponent({ someData }) {
  const someDataRef = useRef(someData);

  useLayoutEffect(() => {
    // Keep the reference up-to-date
    someDataRef.current = someData;
  });

  // The reference of showData is now stable!
  const showData = useCallback(() => {
    console.log("The data is", someDataRef.current);
  }, []);

  return (
    <MemoizedButton type="button" onClick={showData}>
      Show the data, please
    </MemoizedButton>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you have already my article useEvent: the new upcoming hook?, you probably have noticed that it's the same principle. Unfortunately, useEvent will not come soon so we would have to do that a little bit longer in our projects.

Complementary informations

In reality, in the React Hook Form codebase the implementation is not the same.

The ref is updated directly in the render, but I would not recommend you to it because can cause some trouble with new concurrent features and have inconsistency in your components.

function MyComponent({ someData }) {
  const someDataRef = useRef(someData);

  // Do not update directly in the render!!!
  someDataRef.current = someData;

  // But use a `useLayoutEffect`
  useLayoutEffect(() => {
    someDataRef.current = someData;
  });
}
Enter fullscreen mode Exit fullscreen mode

That's the same pattern than the so wanted useEvent hook that will finally not to out :(



Conclusion

You should be more comfortable to browse the React Hook Form and understand the code.
Some of the key points can be used in your own codebase or if you want to develop a library.

Watch out not to too optimize your code. If you want to apply the same pattern with mutation, I recommend to mutate it everytime the data is changing and not to try to not mutate when you think it's not necessary because it can cause you some trouble when your component renders conditionally. For example I would prevent this kind of code which only mutates form state if we use the formState.isDirty on the current render, but will not work when you listen the form state dirty at the next render.


Do not hesitate to comment and if you want to see more, you can follow me on Twitch or go to my Website. And here is a little link if you want to buy me a coffee

Top comments (4)

Collapse
 
iamjaydev profile image
iamjaydev

Great post!

Collapse
 
bluebill1049 profile image
Bill • Edited on

Thanks for the insightful blog post!

which only mutates form state if we use the formState.isDirty on the current render, but will not work when you listen the form state dirty at the next render.

This is actually no related to mutation, because hook form use the Proxy to detect form state subscription. When you have conditional mounted the useFormState, there is no subscription currently set up on the isDirty itself. However, we can resolve this issue by flush down an extra re-render, i will be looking into a solution for such use case.

Here is an issue which i have reported: github.com/react-hook-form/react-h...

Collapse
 
romaintrotard profile image
Romain Trotard

Thank you for your comment Bill :)

This is actually no related to mutation, because hook form use the Proxy to detect form state subscription. When you have conditional mounted the useFormState, there is no subscription currently set up on the isDirty itself. However, we can resolve this issue by flush down an extra re-render, i will be looking into a solution for such use case.

Yep, I understand that currently it doesn't work because there is no component listening to the isDirty state.
But I'm convinced that if some parts of the code are not based on the proxy stuff it will work perfectly. For example, for the mentioned part of the code, if we remove the if it will work like a charm and will not have an extra re-render and "leak" the logic of re-process the dirty value in the useFormState hook.
I made a commit if you want to look at it: github.com/romain-trotard/react-ho...
However, I understand if it's not the mindset you want in the library.

Thank you for the opened and fixed issue :)

Collapse
 
bluebill1049 profile image
Bill

if we remove the if it will work like a charm and will not have an extra re-render and "leak" the logic of re-process the dirty value in the useFormState hook.

That's correct! One of the important aspect when i start design the library is avoid unnecessary computation (render is one part of it), if user is not subscribed to isDirty then I would prefer those comparison logic to be skipped as well, this applied to other form state within the library. again thanks for the post and PR as well <3

Create an Account!

👀 Just want to lurk?

That's fine, you can still create an account and turn on features like 🌚 dark mode.