Introduction
I was introduced to React Hook Forms a couple of weeks ago by a coworker as he was explaining to me how they are using it in their team. My first thought was Who uses a library just for managing forms?
, up until now I’ve just used useState
or similar solutions to store the form state and I was fine with the results.
But then I started checking their documentation and found an interesting thing, when you changed an input field it doesn’t re-render the form…
In comparison this is a normal one:
How are they managing to do so? I wondered… So I started checking their code and this is how it works:
Why does a normal form gets re-rendered when you type a new value
First let’s see a normal form, one that uses useState
to store the value
function MyForm() {
const [firstName, setFirstName] = useState("")
return <form>
<input name="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</form>
}
Every time you type something you trigger a function that changes the value of firstName
and thus trigger a re-render. The thing is that you don’t need in most cases to store this value right away because this value is anyway stored in the HTML.
What is the React Hook Form approach?
React exports many hooks to us, like useState
and useRef
, the latest is usually popular to hold references to DOM elements, but it’s more than that, you can think of the useRef
hook as a way to have instance variables
in a React component, this means that you can have the same value preserved across re-rendering and changing it won’t trigger a re-render.
In that order of ideas, in most cases, it makes sense to store the values of a form in a ref and this is basically what React Hook Forms
do.
Creating a simple replica of React hook forms
To illustrate better this idea, let’s create a basic implementation of React Hook Forms
, let’s call it React Mini Forms
😛
Creating a useForm hook
The first thing we will need is to create a normal function that will be used as a hook. This function will return the following properties:
-
register(fieldName: string)
This function will be called everything we want to add a new field to theform
-
handleSubmit()
This function can be used to handleonSubmit
in forms, it will return the current values of the fields in the form -
watch(fieldName: string)
In some specific cases, we want to do something when a certain field in a form changes, that’s when we want to use this function
We will store the form values in a ref
because as we said earlier, every time we change the value this won’t retrigger a render, inside the useForm
hook, let’s put
// useForm.js
import { useRef, useState } from 'react'
export function useForm() {
const state = useRef()
const [_, setFormState] = useState({ renderCount: 0 }) // Will explain later
// This will be the ref storing the current values of the fields
if(!state.current) {
state.current= {
fields: new Map(),
watching: new Map(),
}
}
return {
register: function(fieldName) {
},
handleSubmit: function() {
},
watch: function(fieldName) {
}
}
}
Consuming the hook
Now in our React Component, we will use the hook in the following way
import { useForm } from './useForm'
function App() {
const { register, handleSubmit, watch } = useForm()
console.log("The current value of the lastName is: ", watch("lastName"))
const onSubmit = (formData) => {
console.log("Data: ", formData)
}
return <form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<button>Submit</button>
</form>
}
Implementing the register function
The register function as we said earlier will be the one in charge of adding new fields to the form state In this function, we will add the field
to the list of fields of the form (If it’s not already there) and we will return all the normal attributes of inputs like name
, onChange
, etc.
register: (name) => {
if (!state.current.fields.has(name))
state.current.fields.set(name)
return {
name,
onChange: (event) => {
state.current.fields.set(
name,
event.target.value,
)
}
}
},
Implementing the watch function
This function was the hardest for me to understand, so here’s a more basic implementation that works. We have a list of fields that the user is currently “watching”. Every time it gets called it adds the field to the watching list if it’s not already there and it returns the current value of the field
watch: (name) => {
if (!state.current.watching.has(name)) {
state.current.watching.set(name, true)
}
return state.current.fields.get(name)
},
Now we need to modify the register
function so every time a value changes and the field is on the watch list, we will purposely call a setState
to tell React that we need to re-render the component
register: (name) => {
// ....
return {
// ...
onChange: (event) => {
if (state.current.watching.has(name)) {
setFormState(state => ({
renderCount: state.renderCount,
}))
}
}
}
},
Implementing the handleSubmit function
This is the easier function, it just returns the current value of all fields
handleSubmit: callback => e => {
e.preventDefault()
callback(state.current.fields)
},
The final code
All this code should be enough to have a working solution but remember that this is a basic implementation to demonstrate the idea, thus it doesn’t have performance improvements and probably it doesn’t handle all edge cases.
One thing to take into account when using this approach is that this could be more performance in a complex component as long as you're not subscribed to all the fields
, if you are subscribed to all the fields a new render will happen every time anyway so a simpler solution would be better.
Let me know any comments you might have!
Here’s the final code See it on Codesanbox 📦
import { useRef, useState } from "react";
export function useForm() {
const state = useRef();
const [_, setFormState] = useState({ renderCount: 0 });
// This will be the ref storing the current values of the fields
if (!state.current) {
state.current = {
fields: new Map(),
watching: new Map()
};
}
return {
register: (name) => {
if (!state.current.fields.has(name)) state.current.fields.set(name);
return {
name,
onChange: (event) => {
if (state.current.watching.has(name)) {
setFormState((state) => ({
renderCount: state.renderCount
}));
}
state.current.fields.set(name, event.target.value);
}
};
},
watch: (name) => {
if (!state.current.watching.has(name)) {
state.current.watching.set(name, true);
}
return state.current.fields.get(name);
},
handleSubmit: (callback) => (e) => {
e.preventDefault();
callback(state.current.fields);
}
};
}