TypeScript: Typing Form Events In React

Updated on · 9 min read
TypeScript: Typing Form Events In React

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:

tsx
export 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> ); };
tsx
export 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.

tsx
export 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> ); };
tsx
export 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.

tsx
const 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); };
tsx
const 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:

markdown
Retrieves a collection, in source order, of all controls in a given form.
markdown
Retrieves 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.

tsx
import 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> ); };
tsx
import 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):

tsx
const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, });
tsx
const [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.

tsx
export 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> ); };
tsx
export 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:

tsx
const onFieldChange = (event: ChangeEvent<HTMLInputElement>) => {};
tsx
const 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:

tsx
else if (event.target.type === "number") { value = event.target.valueAsNumber; }
tsx
else 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:

tsx
let value: (typeof state)[keyof typeof state] = event.target.value;
tsx
let 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:

tsx
import 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> ); };
tsx
import 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.

References and resources