Form handling in React
Simplifying form handling with useActionState
Form handling in React has pretty much evolved around controlling each input by state, attaching different kind of handlers, and has grown in complexity. This has lead to different libraries being made, trying to ease the pain in one way or another, but that means you need to add yet more dependencies to your React project. In the end they make some things easier to manage, but are still somewhat complex to deal with when all you want is getting the values from a form and maintain a good developer and user experience.
Since React 19 we have gotten a new hook though, that makes form handling much easier, and you don‘t need to install yet another library.
This hook is called useActionState().
Let‘s break it down:
[formData, setFormData, isPending] = useActionState(handleSubmit, null)
/*
1. formData (optional name) is the state containing some formData.
2. setFormData (optional name) is the function used in the action prop
of the form or submit button inside a form.
3. isPending is a boolean provided that will change from false to true,
and back when the form is submitted.
4. handleSubmit (optional name) is the form submission handler.
This function can be async, and be placed even outside the component in a
separate file. It does not really matter.
5. null in this case is the initial state. It can be set to an initial
state containing some form data if needed.
*/
We get pretty much what we need from this single hook for our form handling, but this can of course also be combined with other states. We‘ll get back to that.
This is an example of how our handleSubmit function can look like. The form data values can be extracted using get or getAll methods, and we can construct a data object out of it.
If there are any errors for some of the fields, we just return the current object as it is, else we can continue and make a post request.
We can then just return an error if the submission fails, or some sort of flag indicating the form is successfully posted.
type FormValues = {
name: string;
email: string;
gender: string;
country: string;
termsAccepted: string[];
formSubmitted?: boolean;
}
async function handleSubmit(_: unknown, data: FormData, isValidEmail: boolean) {
const formValues = {
name: (data.get("name") ?? "").toString().trim(),
email: (data.get("email") ?? "").toString().trim(),
gender: (data.get("gender") ?? "").toString(),
country: (data.get("country") ?? "").toString(),
termsAccepted: data.getAll("termsCheckboxes"),
} as FormValues
const isIncomplete =
["name", "email", "gender", "country"].some(
(key) => !formValues[key as keyof typeof formValues]
) ||
!isValidEmail ||
!formValues.termsAccepted?.includes("termsAccepted1") ||
!formValues.termsAccepted?.includes("termsAccepted2")
if (isIncomplete) {
return formValues
}
// Do some form submission here, e.g. post data to an API
if (someError) {
return {
error: "An error occurred while submitting the form."
}
} else {
return {
formSubmitted: true
}
}
}
The function takes mainly two arguments. The first argument is the previous state, and the second argument is the current state. Not sure what we can use the previous state for here, but maybe you will get across some use cases for it where that will be useful.
The third argument I have added to the example is a boolean constant I have passed to this function. That can be achieved by extending the form submit handler as an inline arrow function (or a separate function) like this:
const [formData, setFormData, isPending] = useActionState(
(prevState:unknown, newFormData:FormData) => handleSubmit(prevState, newFormData, isValidEmail), null
)
const [isValidEmail, setIsValidEmail] = useState<boolean>(true)
This is just an example of a simple email validation helper function we can use to validate the email input:
const validateEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value !== '') {
const email = e.target.value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
setIsValidEmail(emailRegex.test(email));
} else {
setIsValidEmail(true);
}
}
With the markup for the form, we end up with something like this:
const RegistrationForm = () => {
const [formData, setFormData, isPending] = useActionState(
(prevState:unknown, newFormData:FormData) => handleSubmit(prevState, newFormData, isValidEmail), null
)
const [isValidEmail, setIsValidEmail] = useState<boolean>(true)
const validateEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value !== '') {
const email = e.target.value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
setIsValidEmail(emailRegex.test(email));
} else {
setIsValidEmail(true);
}
}
return (
<form action={setFormData} className="mb-form flex flex-dir-col gam">
<div>
<label htmlFor="name">
Name
{formData?.name === "" && (
<span className="error-help db">Name is required</span>
)}
</label>
<input
id="name"
type="text"
name="name"
defaultValue={formData?.name}
/>
</div>
<div>
<label htmlFor="email">
Email
{(formData?.email === "" || !isValidEmail) && (
<span className="error-help db">
{!isValidEmail ? "Invalid email address" : "Email is required"}
</span>
)}
</label>
<input
id="email"
type="email"
name="email"
onBlur={validateEmail}
defaultValue={formData?.email}
/>
</div>
<fieldset>
<legend>
Gender
{formData?.gender === "" && (
<span className="error-help db">Gender is required</span>
)}
</legend>
<div>
<input
id="male"
type="radio"
name="gender"
value="male"
defaultChecked={formData?.gender === "male"}
/>
<label htmlFor="male">
Male
</label>
</div>
<div>
<input
id="female"
type="radio"
name="gender"
value="female"
defaultChecked={formData?.gender === "female"}
/>
<label htmlFor="female">
Female
</label>
</div>
</fieldset>
<div>
<label htmlFor="country">
Country
{formData?.country === "" && (
<span className="error-help db">Country is required</span>
)}
</label>
<select id="country" name="country" defaultValue={formData?.country}>
<option value="norway">Norway</option>
<option value="sweden">Sweden</option>
<option value="denmark">Denmark</option>
</select>
</div>
<div>
<div>
<input
id="termsAccepted"
type="checkbox"
name="termsCheckboxes"
defaultChecked={formData?.termsAccepted?.includes("termsAccepted") || false}
value="termsAccepted"
/>
<label htmlFor="termsAccepted">
I accept the terms and conditions
</label>
{formData &&
!formData.formSubmitted &&
!formData?.termsAccepted?.includes("termsAccepted") && (
<span className="error-help db">You must accept the terms</span>
)
}
</div>
<div>
<input
id="termsAccepted"
type="checkbox"
name="termsCheckboxes"
defaultChecked={formData?.termsAccepted?.includes("termsAccepted") || false}
value="termsAccepted"
/>
<label htmlFor="termsAccepted">
I accept something else
</label>
{formData &&
!formData.formSubmitted &&
!formData?.termsAccepted?.includes("termsAccepted") && (
<span className="error-help db">You must accept something else</span>
)
}
</div>
</div>
<button disabled={isPending}>
Register
</button>
</form>
)
}
export default RegistrationForm
There are some key differences in the form now that we should be aware of, but that actually make our form handling quite cleaner and easier to maintain.
- We don‘t need to manage a bunch of states for inputs, errors, handlers etc.
- We use
setFormData(example) in theactionattribute - We can simply use
formDataand render what we need based on the return value ofhandleSubmit(in this example). - All submission logic is gathered inside one single function.
- When submitting the form,
e.preventDefault()is handled for us. - We don‘t need any
onChangeoronSubmithandlers on the input elements. - The
valueprop is replaced bydefaultValueordefaultChecked, making the input elements uncontrolled (by state). - We get
isPendingfor free. No additional state for pending is needed (in most cases at least). - We can combine this with some other state if needed.
- No rerendering of components while typing. We mainly need the values from the inputs elements, but we can set up some state for some conditional rendering if needed.
To wrap it up; we can pretty much make the form handling in React much easier now using the useActionState hook. It is a powerful hook that provides us pretty much everything we need for handling forms in React, and we get great DX and UX out of it.