Easy React Forms with FormData
Storing each form input value in React state can be tedious, but it doesn't have to be that way.
Storing form state in React
I often see folks using state to store each individual value of a form:
export function ExampleForm() { const [username, setUsername] = React.useState('') const [password, setPassword] = React.useState('') const [email, setEmail] = React.useState('') return ( <form onSubmit={ev => { ev.preventDefault() const body = { username, password, email } fetch('/register', { method: 'post', body: JSON.stringify(body) }) }} > <div> <label> <span>username:</span> <input value={username} onChange={ev => setUsername(ev.target.value)} /> </label> </div> <div> <label> <span>password:</span> <input value={password} onChange={ev => setPassword(ev.target.value)} /> </label> </div> <div> <label> <span>email:</span> <input value={email} onChange={ev => setEmail(ev.target.value)} /> </label> </div> </form> )}
This works fine, but it's tedious to add event handlers to every form input, and if you later decide to add another form input, you'll be adding another state hook, and that's without mentioning the extra work you're asking React to do on every keystroke..
All of this, just because you want to grab values from a form on submission?
Fortunately there's a simpler way!
FormData API to the rescue
The FormData
api has been around for a while, but it doesn't seem to get used much.
When given a form element, it returns a FormData
object of all the inputs with a name
attribute. The input must have a name
attribute or it not be available.
In a React form we can pass the form element to FormData
by accessing it from the event given to the callback:
<form onSubmit={ev => { console.log(new FormData(ev.currentTarget))}}>
This returns a FormData
object, not a plain ol JavaScript object.
Passing a
FormData
object tofetch
will also force theContent-Type
of the request to bemultipart/form-data
which is a very different kind of request thanapplication/x-www-form-urlencoding
orapplication/json
.
If you have files to upload with your form, then this is ideal. You can pass the FormData
object to your fetch
request and be on your way:
<form onSubmit={ev => { ev.preventDefault() const body = new FormData(ev.currentTarget) fetch('/update-profile', { method: 'post', body })}}>
Again, your form inputs must have a name
attribute in order to be picked up by FormData
.
If we don't have files to upload and we want to send a application/json
request, we can do the following:
<form onSubmit={ev => { ev.preventDefault() const body = Object.fromEntries(new FormData(ev.currentTarget)) fetch('/new-post', { method: 'post', body: JSON.stringify(body) })}}>
Or if we want to send a application/x-www-form-urlencoding
request, we can use create a URLSearchParams
object:
<form onSubmit={ev => { ev.preventDefault() const body = new URLSearchParams(new FormData(ev.currentTarget)) fetch('/search', { method: 'get', body })}}>
Here's full working examples of both so you can compare:
with FormData api
with controlled inputs
as diff
export function ExampleForm() { return ( <form onSubmit={ev => { ev.preventDefault() const body = Object.fromEntries(new FormData(ev.currentTarget)) fetch('/register', { method: 'post', body: JSON.stringify(body) }) }} > <div> <label> <span>username:</span> <input name="username" /> </label> </div> <div> <label> <span>password:</span> <input name="password" /> </label> </div> <div> <label> <span>email:</span> <input name="email" /> </label> </div> </form> )}
Reset state
Sometimes you need to reset the form after submission. What do you do when you don't control the inputs with state?
useRef
!
export function ExampleForm() { const formRef = useRef(null) return ( <form ref={formRef} onSubmit={ev => { ev.preventDefault() const body = Object.fromEntries(new FormData(ev.currentTarget)) fetch('/new-post', { method: 'post', body: JSON.stringify(body) }).then(res => { if (res.ok) { formRef.current?.reset() } }) }} >
Validation
"What about validation", you ask?
There's a lot you can do with HTML attributes like required
, pattern
, minlength
, maxlength
, and there's a lot you can do before submission without controlled inputs, but there are some things that you'll want controlled inputs for. Fortunately it doesn't have to be an all-or-nothing thing, it's possible to have the majority of your inputs uncontrolled and only sprinkle state around when necessary.
Here's an example of only using state to show validation errors:
import * as React from "react" const validators = { title(value) { if (typeof value !== "string") return "title must be a string" if (value.length < 3 || value.length > 64) return "title must be between 3 and 64 characters" }, body(value) { if (typeof value !== "string") return "body must be a string" if (value.length < 1) return "body must not be empty" if (value.length > 128) return "body must be between less than 128 characters" }, } export default function ExampleForm() { const formRef = React.useRef(null) const [errors, setErrors] = React.useState({}) function validate(ev) { const { name, value } = ev.currentTarget const fieldError = validators[name](value) setErrors(e => ({ ...e, [name]: fieldError })) } return ( <form ref={formRef} onReset={() => setErrors({})} onSubmit={ev => { ev.preventDefault() const fields = Object.fromEntries(new FormData(ev.currentTarget)) // map field values to their validator results const fieldErrors = Object.entries(validators) .map(([name, fn]) => [name, fn(fields[name])]) const hasError = fieldErrors.some(([_name, value]) => Boolean(value)) if (hasError) { return setErrors(Object.fromEntries(fieldErrors)) } // in a real app you would fetch here console.log("fetch", { body: fields }) formRef.current?.reset() }} > <div> <label> <span>title: </span> <input type="text" name="title" onChange={validate} aria-invalid={Boolean(errors.title)} aria-errormessage={errors.title ? "form-title-error" : undefined} /> </label> {errors.title && ( <div id="form-title-error" aria-live="assertive"> {errors.title} </div> )} </div> <div> <label> <span>body: </span> <textarea name="body" onChange={validate} aria-invalid={Boolean(errors.body)} aria-errormessage={errors.body ? "form-body-error" : undefined} /> </label> {errors.body && ( <div id="form-body-error" aria-live="assertive"> {errors.body} </div> )} </div> <div> <button type="submit">send</button> <button type="reset">reset</button> </div> </form> ) }
Keep in mind that validation in the browser is purely for the user experience, and is not a replacement for server side validation.
I hope this makes working with forms in React easier for you!