Skip to content

How to type a React form onSubmit handler

by Kent C. Dodds

Abstract illustration of a form labeling sections with TypeScript and React types

Here's a form in JSX:

function UsernameForm({onSubmitUsername}) {
function handleSubmit(event) {
event.preventDefault()
onSubmitUsername(event.currentTarget.elements.usernameInput.value)
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="usernameInput">Username:</label>
<input id="usernameInput" type="text" />
</div>
<button type="submit">Submit</button>
</form>
)
}

Let's type that handleSubmit function. Here's how some people do it (copying this approach from some blog posts and "semi-official guides" I've seen):

function handleSubmit(event: React.SyntheticEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formElements = form.elements as typeof form.elements & {
usernameInput: {value: string}
}
onSubmitUsername(formElements.usernameInput.value)
}

The reason we have to have the as there is because TypeScript isn't quite smart enough to know what elements we're rendering in our form, so we have to use the type cast which I'm not a fan of, but you gotta ship right?

My first improvement to this is to change that usernameInput type:

- usernameInput: {value: string}
+ usernameInput: HTMLInputElement

Definitely take advantage of the type it actually is rather than just cherry-picking the values you need.

The second improvement is:

- function handleSubmit(event: React.SyntheticEvent<HTMLFormElement>) {
+ function handleSubmit(event: React.FormEvent<HTMLFormElement>) {

Incidentally, at the time of this writing, there's no substantive difference in those types, but I prefer to be more clear and accurate with the name of the type, so that's what we're going to go with.

So here we are now:

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formElements = form.elements as typeof form.elements & {
usernameInput: HTMLInputElement
}
onSubmitUsername(formElements.usernameInput.value)
}

But even with those changes, I'm not a fan of this for three reasons:

  1. It's a bit verbose and distracts from the main purpose of the function
  2. If I need to use that form anywhere else, I'll either have to duplicate the code or extract the type and cast it everywhere I use the form.
  3. I don't like seeing as in my code because it is a signal that I'm telling the TypeScript compiler to pipe down (be less helpful). So I try to avoid it when possible. And it is possible!

Keep in mind that we're the ones telling TypeScript what that event.currentTarget type is. We tell TypeScript when we specify the type for our event. Right now that's set to React.FormEvent<HTMLFormElement>. So we're telling TypeScript that event.currentTarget is an HTMLFormElement but then we immediately tell TypeScript that this isn't quite right by using as. What if instead we just tell TypeScript more accurately what it is at the start? Yeah, let's do that.

interface FormElements extends HTMLFormControlsCollection {
usernameInput: HTMLInputElement
}
interface UsernameFormElement extends HTMLFormElement {
// now we can override the elements type to be an HTMLFormControlsCollection
// of our own design...
readonly elements: FormElements
}

So in reality, our form is an HTMLFormElement with some known elements. So we extend HTMLFormElement and override the elements to have the elements we want it to. The HTMLFormElement['elements'] type is a HTMLFormControlsCollection, so make our own version of that interface as well.

With that, now we can update our type and get rid of all the type casting!

Here's the whole thing altogether:

import * as React from 'react'
interface FormElements extends HTMLFormControlsCollection {
usernameInput: HTMLInputElement
}
interface UsernameFormElement extends HTMLFormElement {
readonly elements: FormElements
}
function UsernameForm({
onSubmitUsername,
}: {
onSubmitUsername: (username: string) => void
}) {
function handleSubmit(event: React.FormEvent<UsernameFormElement>) {
event.preventDefault()
onSubmitUsername(event.currentTarget.elements.usernameInput.value)
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="usernameInput">Username:</label>
<input id="usernameInput" type="text" />
</div>
<button type="submit">Submit</button>
</form>
)
}

So now:

  1. the type information is removed from the handleSubmit code, allowing us to focus on what the function is doing.
  2. If we need to use this form somewhere else, we can give it the UsernameFormElement type and get all the type help we need.
  3. We don't have to use a type cast, so TypeScript can be more useful for us.

I hope that's helpful to you!

Get my free 7-part email course on React!

Delivered straight to your inbox.