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>
);
}
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>
);
}
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);
}
}
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,
};
}
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,
};
}
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;
}
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);
}
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>
);
}
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>
);
}
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.
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. That's the same pattern than the so wanted Complementary informations
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;
});
}
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)
Great post!
Thanks for the insightful blog post!
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 theisDirty
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...
Thank you for your comment Bill :)
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 theuseFormState
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 :)
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