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 to fetch will also force the Content-Type of the request to be multipart/form-data which is a very different kind of request than application/x-www-form-urlencoding or application/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-error-title" 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-error-body" : undefined}
          />
        </label>
        {errors.body && (
          <div id="form-error-body" 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!

Comments