Writing

React 101: The Principles and Implementation of React Hook Form

文章發表於

Introduction

This article will take a deeper look at how the react-hook-form library itself is implemented.

Basic Concepts

In React, the only way to trigger a re-render is by updating state using useState or useReducer. When state changes, React uses the Reconciliation algorithm to traverse the entire element tree, compare it with the previous tree, identify which nodes need updating, and finally make the corresponding updates to the DOM tree.

It's worth noting that re-renders come with a cost. Without proper optimization, poor performance can cause the entire page to drop frames - what users commonly refer to as a "laggy" interface. This is exactly the problem that react-hook-form aims to solve. Next, we'll explore how it addresses this issue using the Observer pattern.

Observer Pattern

The Observer pattern has become quite common in recent years, especially in libraries that need to manage state, such as Zustand or react-hook-form.

The core logic revolves around registering listeners, where only when state changes are the registered parties notified. Any data changes are first stored in an internal data structure, without directly hooking into React via useState. Although this requires writing more logic to monitor internal data changes and notify React when to re-render, it's a more efficient approach to mitigate the cost of re-renders.

The example below briefly demonstrates the Zustand source code and shows how Zustand optimizes React performance through listeners - this is also similar to the core logic of react-hook-form!

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { createSubject, useStore } from './zustand';
import { preinit} from 'react-dom'


const formStore = createSubject({
  firstName: '',
  lastName: '',
  email: '',
});

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}

function FirstNameDisplay() {
  const firstName = useStore(formStore, (state) => state.firstName);
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="FirstNameDisplay" />
      <p className="text-gray-600">First name: <span className="font-bold text-black">{firstName}</span></p>
    </div>
  );
}

function LastNameDisplay() {
  const lastName = useStore(formStore, (state) => state.lastName);
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="LastNameDisplay" />
      <p className="text-gray-600">Last name: <span className="font-bold text-black">{lastName}</span></p>
    </div>
  );
}

function FirstNameInput() {
  const firstName = useStore(formStore, (state) => state.firstName);

  const handleChange = (e) => {
    formStore.setState({ firstName: e.target.value });
  };

  return (
    <div className="relative">
      <RenderCounter name="FirstNameInput" />
      <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">First name</label>
      <input
        type="text"
        id="firstName"
        value={firstName}
        onChange={handleChange}
        className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
      />
    </div>
  );
}

function LastNameInput() {
    const lastName = useStore(formStore, (state) => state.lastName);

    const handleChange = (e) => {
        formStore.setState({ lastName: e.target.value });
    };

    return (
        <div className="relative">
            <RenderCounter name="LastNameInput" />
            <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">Last name</label>
            <input
                type="text"
                id="lastName"
                value={lastName}
                onChange={handleChange}
                className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
        </div>
    );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub Pattern Form</h1>
        <p className="text-gray-500 mb-6">
          Observe the render counter in the top right. When you type in an input field, only the components related to that input field will re-render.
        </p>

        <div className="space-y-4">
          <FirstNameInput />
          <LastNameInput />
        </div>

        <div className="mt-8 space-y-4">
          <h2 className="text-lg font-semibold text-gray-700">Display Area</h2>
          <FirstNameDisplay />
          <LastNameDisplay />
        </div>
      </div>
    </div>
  );
}

The subscribe function in createSubject establishes observers and stores internal state in state, getState reads the internal state data, and setState merges new and old state whenever there are data changes, then broadcasts to all listening observers. useStore can be seen as the bridge between internal state and React components. Even though every internal state update broadcasts the latest state to registered listeners, using a selector to only listen for necessary state updates prevents unnecessary re-renders.

API

createSubject

createSubject shares the same core concept as the Zustand example above, so we won't elaborate further. If you want to see react-hook-form's implementation, you can refer to here - the core logic is the same!

function createSubject(initialState) {
const _observers = new Set();
const next = (value) => {
for (const observer of _observers) {
observer.next && observer.next(value);
}
};
const subscribe = (callback) => {
_observers.add(callback);
return {
unsubscribe: () => {
_observers.delete(callback);
},
};
};
const unsubscribe = () => {
_observers.clear();
}
return { next, subscribe, unsubscribe };
}

createFormControl

createFormControl is the heart of the entire react-hook-form library. It controls various form states (isSubmitted, error, ...) and the logic of core APIs (register, handleSubmit, ...). Before implementing it, let's review the two most important APIs: register and handleSubmit:

  • register takes a field name and binds that field to react-hook-form's internal state. This API returns onChange, onBlur, name, and ref.

    <input {...register("firstName")} placeholder="First name" />
  • handleSubmit is a curried function that first receives an onSubmit function, then the form's evt. When the form triggers submission, this API performs internal state updates, validates the form, and finally calls onSubmit.

    const { register, handleSubmit } = useForm();
    const onSubmit = (data) => console.log(data) // value from first name
    <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register("firstName")} placeholder="First name" />
    </form>

Let's start with the starter code for createFormControl. There are three types of internal state: _fields stores field metadata, _formValues is primarily used to update field values, and _formState stores any form-related information like isSubmitting, isSubmitted, etc.

APIs like getValues and setValue follow concepts similar to the Zustand source code we discussed earlier. Next, we'll explore how register and handleSubmit are implemented.

function createFormControl() {
let _fields = {};
let _formValues = {};
let _formState = {
isSubmitting: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
};
const _subjects = {
state: createSubject()
};
const setValue = (name, value) => {
_formValues[name] = value;
_subjects.state.next({
name,
values: _formValues,
});
};
const getValues = () => _formValues;
const handleSubmit = (onSubmit) => async (evt) => {
if (evt) {
evt.preventDefault && evt.preventDefault();
evt.persist && evt.persist();
}
// TODO: adding the submit logic
};
const register = (name) => {
// TODO: adding the register logic
return {
name: name,
onChange: () => {},
onBlur: () => {},
ref: null
}
}
return {
setValue,
getValues,
register,
handleSubmit
}
}
export default createFormControl;

register

register is actually a relatively straightforward API. Its main purpose is to inject the name as an identifier into _fields and return name, onChange, and ref. Whenever onChange is triggered, it updates the latest value in formValues.

const register = (name) => {
if (!_fields[name]) {
_fields[name] = {
_f: { name, ref: null },
}
}
return {
name: name,
onChange: (evt) => {
setValue(name, evt.target.value)
},
ref: (element) => {
_fields[name]._f.ref = element;
}
}
}

handleSubmit

What handleSubmit does is update the _formState at each stage - from form data submission to successful or failed response. Each stage broadcasts updates to listeners.

const _updateFormState = (updates) => {
_formState = { ..._formState, ...updates };
_subjects.state.next({
...updates,
values: _formValues,
});
};
const handleSubmit = (onSubmit) => async (evt) => {
if (evt) {
evt.preventDefault && evt.preventDefault();
evt.persist && evt.persist();
}
_updateFormState({ isSubmitting: true });
try {
const submittedValue = structuredClone(_formValues)
await onSubmit(submittedValue);
_updateFormState({
isSubmitted: true,
isSubmitting: false,
isSubmitSuccessful: true,
submitCount: _formState.submitCount + 1,
});
} catch (err) {
_updateFormState({
isSubmitted: true,
isSubmitting: false,
isSubmitSuccessful: false,
submitCount: _formState.submitCount + 1,
});
}
};

useForm

useForm serves as the bridge connecting react-hook-form's internal APIs with React.

import { useRef } from "react";
import createFormControl from "../core/createFormContorl";
function useForm() {
const controlRef = useRef(null);
if (!controlRef.current) {
controlRef.current = createFormControl();
}
return {
register: controlRef.current.register,
setValue: controlRef.current.setValue,
getValues: controlRef.current.getValues,
handleSubmit: controlRef.current.handleSubmit,
// Expose internal refs for advanced hooks (useWatch, useFormState)
control: controlRef.current,
};
}
export default useForm;

Our mini version of react-hook-form is almost complete! At this point, we can use the useForm API to register input fields in memory and return the current form values when the form is submitted.

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { preinit} from 'react-dom'
import useForm from './useForm';

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  const { register, handleSubmit } = useForm();
  const onSubmit = (data) => console.log('--->', data)

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub Pattern Form</h1>
        <p className="text-gray-500 mb-6">
          Observe the render counter in the top right. When you type in an input field, only the components related to that input field will re-render.
        </p>

        <form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
          <div className="relative">
            <RenderCounter name="FirstNameInput" />
            <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">First name</label>
            <input
              type="text"
              id="firstName"
              {...register("firstName")}
              className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
          </div>
          <div className="relative">
              <RenderCounter name="LastNameInput" />
              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">Last name</label>
              <input
                  type="text"
                  id="lastName"
                  {...register("lastName")}
                  className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              />
          </div>
          <button type="submit">Submit</button>
        </form>
      </div>
    </div>
  );
}

useWatch

Sometimes we might need to monitor changes to specific values within form fields - this is what useWatch does. You can pass the field name name you want to watch, which can be an array (to watch multiple fields), a string (to watch a single field), or undefined (to watch all fields). This allows us to re-render only when specific values change, similar to what we demonstrated earlier with Zustand.

import { useState, useLayoutEffect, useEffect, useRef } from 'react';
import { preinit } from 'react-dom'
import useForm from './useForm';
import useWatch from './useWatch';

function RenderCounter({ name }) {
  const count = useRef(0);
  count.current += 1;
  return (
    <span className="absolute top-1 right-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded">
      {name} Render: {count.current}
    </span>
  );
}


function FirstNameDisplay({ control }) {
  const firstName = useWatch({ control, name:"firstName" });
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="FirstNameDisplay" />
      <p className="text-gray-600">First name: <span className="font-bold text-black">{firstName}</span></p>
    </div>
  );
}

function LastNameDisplay({ control }) {
  const lastName = useWatch({ control, name:"lastName" });
  return (
    <div className="relative p-4 border rounded-lg bg-gray-50">
      <RenderCounter name="LastNameDisplay" />
      <p className="text-gray-600">Last name: <span className="font-bold text-black">{lastName}</span></p>
    </div>
  );
}

export default function App() {
  preinit("https://cdn.tailwindcss.com", { as: "script", fetchPriority: "high" });

  const { register, handleSubmit, control } = useForm();
  const onSubmit = (data) => console.log('--->', data)

  return (
    <div className="bg-gray-100 min-h-screen p-8 font-sans">
      <div className="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-lg">
        <h1 className="text-2xl font-bold text-gray-800 mb-2">Pub-Sub Pattern Form</h1>
        <p className="text-gray-500 mb-6">
          Observe the render counter in the top right. When you type in an input field, only the components related to that input field will re-render.
        </p>

        <form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
          <div className="relative">
            <RenderCounter name="FirstNameInput" />
            <label htmlFor="firstName" className="block text-sm font-medium text-gray-700">First name</label>
            <input
              type="text"
              id="firstName"
              {...register("firstName")}
              className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
            />
          </div>
          <div className="relative">
              <RenderCounter name="LastNameInput" />
              <label htmlFor="lastName" className="block text-sm font-medium text-gray-700">Last name</label>
              <input
                  type="text"
                  id="lastName"
                  {...register("lastName")}
                  className="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              />
          </div>
          <button type="submit">Submit</button>
        </form>

         <div className="mt-8 space-y-4">
           <h2 className="text-lg font-semibold text-gray-700">Display Area</h2>
           <FirstNameDisplay control={control} />
           <LastNameDisplay control={control} />
         </div>
      </div>
    </div>
  );
}

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Buy me a coffee