TypeScript: Typing Form Events In React
Updated on · 9 min read|Handling user interactions and events is crucial for creating a dynamic and engaging user experience in web development. React, a popular JavaScript library, makes it easy to manage these events, and its approach is quite similar to handling events on DOM elements. However, there are a few differences and unique aspects that developers need to be aware of to get the most out of React event handling. For instance, event names in React follow the camel case convention, whereas in the DOM they are all lowercase. Additionally, in React, the function itself is passed as the event handler instead of its name in string form.
The most significant difference, however, is that in React the event handlers will receive as an argument a React event object, more commonly known as a "synthetic event". They are not 100% equivalent to the browser's native events, although the native event can still be accessed if necessary via the nativeEvent
property of the event object. As a result, typing form events in React differs from native events, with React providing its own types.
In this post, we'll see how to type form events in React on the example of a simple form component. We will discuss how to properly type the event handlers for both controlled and uncontrolled forms, and highlight some best practices to follow when building forms in React.
Form setup
Let's use this simple signup form as an example:
tsxexport const Form = () => { return ( <form className="form"> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
tsxexport const Form = () => { return ( <form className="form"> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
If we want to collect form data and send it to a server, we have two main choices. The first option is to leave the form uncontrolled and get the data in the onSubmit
callback. The second option is to save the data in the form's state and send it when the form is submitted. We'll consider both approaches here.
Uncontrolled form
To collect the form data when it's submitted, we'll add an onSubmit
callback and get the data from each element using the element's name
property.
tsxexport const Form = () => { const onSubmit = (event: any) => { event.preventDefault(); // Validate form data // ... const target = event.target; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
tsxexport const Form = () => { const onSubmit = (event: any) => { event.preventDefault(); // Validate form data // ... const target = event.target; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
For the sake of simplicity, data validation and error handling (as well as CSS styling) have been omitted in this code. To prevent the page from reloading before we collect the form data, it's important to call event.preventDefault() at the top of the callback function. Returning false
from the callback won't work here since synthetic events and native DOM events behave differently in this case.
To collect form data, we can either access it directly from the event's target by the element's id
or through the target's elements
property. For example, both event.target.email.value
and event.target.elements.email.value
work. We can also use the name
attribute to retrieve form element values, but since ids are already set up to connect inputs to their labels through the htmlFor
attribute, we'll use the id
attribute.
Note that the event type is currently any
, meaning there's no type checking. To address this, we need to define the type of the event
for the onSubmit
callback.
For synthetic events, we can use the type definitions provided by React. The first choice would be to use React.FormEvent
with the HTMLFormElement
type argument. However, this won't work for our form because we want to retrieve data from the target
attribute, which by default has the generic EventTarget
type. FormEvent
type eventually boils down to BaseSyntheticEvent<E, EventTarget & T, EventTarget>
, where the last EventTarget
argument is used for the target
attribute type. It seems like we don't have control over the type of target attribute, and even if we did (we can always assert the type if needed) it still doesn't know which form elements our form has.
Looking deeper into the type definitions of BaseSyntheticEvent
, we can see that the second type argument EventTarget & T
gets assigned to the currentTarget
property of the event. Therefore, it seems like to fix our type issue we just need to switch from event.target
to event.currentTarget
.
tsxconst onSubmit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); const target = event.currentTarget; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); };
tsxconst onSubmit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); const target = event.currentTarget; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); };
This approach almost works and would function correctly if we didn't have an input element with an id of name
in the form. As it currently stands, our currentTarget.name
overrides the name
attribute of the HTMLFormElement
. However, there is a more significant issue with this method: the lack of proper type checking for target attributes.
For instance, attempting to access a non-existent form element via target.address.value
would not be caught by TypeScript because it is unaware of the elements present in the form. A more effective solution would involve defining the id-element pairs and typing the current target accordingly.
To accomplish this, we need to examine the type definition of HTMLFormElement
. We can see that it includes readonly elements: HTMLFormControlsCollection;
, which comes with useful documentation:
markdownRetrieves a collection, in source order, of all controls in a given form.
markdownRetrieves a collection, in source order, of all controls in a given form.
This refers to the event.target.elements
attribute we discussed earlier, except in this case, it is on the currentEvent
.
By connecting all the pieces, it appears we can extend HTMLFormControlsCollection
with our form elements and then overwrite HTMLFormElement.elements
with that interface.
tsximport React, { FormEvent } from "react"; import "./form.css"; interface CustomElements extends HTMLFormControlsCollection { name: HTMLInputElement; email: HTMLInputElement; password: HTMLInputElement; confirmPassword: HTMLInputElement; conditionsAccepted: HTMLInputElement; } interface CustomForm extends HTMLFormElement { readonly elements: CustomElements; } export const Form = () => { const onSubmit = (event: FormEvent<CustomForm>) => { event.preventDefault(); const target = event.currentTarget.elements; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
tsximport React, { FormEvent } from "react"; import "./form.css"; interface CustomElements extends HTMLFormControlsCollection { name: HTMLInputElement; email: HTMLInputElement; password: HTMLInputElement; confirmPassword: HTMLInputElement; conditionsAccepted: HTMLInputElement; } interface CustomForm extends HTMLFormElement { readonly elements: CustomElements; } export const Form = () => { const onSubmit = (event: FormEvent<CustomForm>) => { event.preventDefault(); const target = event.currentTarget.elements; const data = { name: target.name.value, email: target.email.value, password: target.password.value, confirmPassword: target.confirmPassword.value, conditionsAccepted: target.conditionsAccepted.checked, }; console.log(data); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditionsAccepted" /> <label htmlFor="conditionsAccepted"> I agree to the terms and conditions </label> </div> <button type="submit">Sign up</button> </form> ); };
This takes care of the type issue and also provides proper type checking when accessing the values of the form elements.
If you are curious about using TypeScript to provide the types for the
useRef
hook in React, you may find this post helpful: TypeScript: Typing
Form Events In
React.
Controlled form
Although it is useful to know how to properly type uncontrolled form event handlers, this kind of form is less common in React components. Most of the time, we want the form to have controlled elements, with values and callbacks that can also come from the parent components. In such cases, it's common to save the form values onto the component's state and then send them to the server when the form is submitted, or even without using the form's submit event. Fortunately, typing such event handlers is more straightforward than the previous form example.
As an example, let's save the values entered by a user onto the state and then send them via an API when the user clicks Sign up. For simplicity's sake, we'll use one state object (similar to how the state is saved in class-based components):
tsxconst [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, });
tsxconst [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, });
Although it's often preferable to use the useReducer
hook to handle complex state, useState
works for this demonstration. To update the form elements' values on the state, we'll need to assign onChange
handlers to each of the fields. The most obvious course of action here is to assign a different onChange
handler to each element, for example:
tsx<input id="name" onChange={(event) => setState({ ...state, name: event.target.value })} />
tsx<input id="name" onChange={(event) => setState({ ...state, name: event.target.value })} />
Handling changes in multiple form elements
However, there is an easier way. Similar to how the form's submit event has all the elements' ids or names saved onto the target, the change event's target has name
and id
attributes, which can be used to assign the respective element's value to the state. As we already have the ids defined on the fields (and these ids have the same names as the fields we're collecting the data from), we'll use them to match field data to the state.
There's a minor issue with this approach, though, and that is that we have one checkbox element. For checkbox elements, we're looking for the checked
property instead of the value. In such cases, it's acceptable to have a separate handler for each element type, such as onInputChange
and onCheckboxChange
. However, since we have a simple form with only one checkbox, let's add a condition to the handler, which will retrieve the checked
property for the checkbox field based on the target's type.
tsxexport const Form = () => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event: any) => { let value = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); console.log(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label htmlFor="conditions">I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };
tsxexport const Form = () => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event: any) => { let value = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); console.log(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label htmlFor="conditions">I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };
If you are curious about using TypeScript template literal types to enhance code safety and maintainability, you may find this post helpful: TypeScript: Typing Form Events In React.
TypeScript types for the onChange event in form elements
Note that we also reverted to the FormEvent<HTMLFormElement>
type for the submit event, since we no longer access the values from it. Now we just need to type the onFieldChange
event. Initially, we try to type the event as React.ChangeEvent
, but that doesn't seem sufficient, as we encounter a type error indicating that all the properties we're trying to access do not exist on the event's target, for example: "Property 'value' does not exist on type 'EventTarget & Element'".
Upon examining the definition for ChangeEvent
, we can see that it accepts a type argument, which defaults to a generic Element
. Since all the form's elements are inputs, we'll use the HTMLInputElement
type argument for ChangeEvent
:
tsxconst onFieldChange = (event: ChangeEvent<HTMLInputElement>) => {};
tsxconst onFieldChange = (event: ChangeEvent<HTMLInputElement>) => {};
Now all the target's attributes are properly recognized; however, a new issue appears when reassigning event.target.checked
to the value - "Type 'boolean' is not assignable to type 'string'".
This occurs because when we declare the value with let value = event.target.value;
, TypeScript infers its type to be a string, which is the type for the input's value. To fix this, we need to let TypeScript know that our value can also be a boolean: let value: string | boolean = event.target.value;
. This solution works well, but wouldn't it be better if TypeScript could automatically infer the type of value based on the state?
For instance, let's say we add a new field age
, which is an input with a type of number. To save it onto the state, we'd add this block to onFieldChange
:
tsxelse if (event.target.type === "number") { value = event.target.valueAsNumber; }
tsxelse if (event.target.type === "number") { value = event.target.valueAsNumber; }
To eliminate the TypeScript error, we'd have to update the type of value to boolean | string | number
. However, if we eventually decided to remove the age
field, we'd also need to remember to update the value types again. We could define a separate State
type, where we declare the types for all the fields, but there's a better way.
We already have the types of state values available from the state, and we know that those types are the only ones each field will have. In such cases, it's better to infer the types from existing data, as this will automatically keep them in sync in case the data structure changes. To infer the types from the state, we can use this TypeScript notation:
tsxlet value: (typeof state)[keyof typeof state] = event.target.value;
tsxlet value: (typeof state)[keyof typeof state] = event.target.value;
Here, we're telling TypeScript that we expect value
to be the type of the values present on the state, and if any of them change in the future, those changes will be automatically reflected in the type of value
.
If you want to know more about how to simplify the connected props in your React components, you may find this post helpful: Simplifying Connected Props with Redux and TypeScript.
The final code for the form looks like this:
tsximport React, { ChangeEvent, FormEvent, useState } from "react"; import "./form.css"; export const Form = () => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event: ChangeEvent<HTMLInputElement>) => { let value: (typeof state)[keyof typeof state] = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); console.log(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label htmlFor="conditions">I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };
tsximport React, { ChangeEvent, FormEvent, useState } from "react"; import "./form.css"; export const Form = () => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event: ChangeEvent<HTMLInputElement>) => { let value: (typeof state)[keyof typeof state] = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); console.log(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="email">Email</label> <input type="email" id="email" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="password">Password</label> <input type="password" id="password" onChange={onFieldChange} /> </div> <div className="field"> <label htmlFor="confirmPassword">Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label htmlFor="conditions">I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };
Conclusion
In conclusion, understanding how to properly type form events in React is crucial for creating dynamic, engaging, and robust web applications. The article provided a step-by-step guide on how to define TypeScript input types and how they are used in conjunction with React's synthetic events. We dived into creating custom event handlers for onSubmit
event types and demonstrated the importance of typing form elements and event handlers for a more reliable and robust application. Additionally, we examined event.target.value
in TypeScript and its relevance in extracting user input from form elements. By following the guidelines and best practices shared in this article, you will be better equipped to tackle the challenges of form handling in your React applications using TypeScript.