TypeScript: Typing form events in React

typescript react webdev

Image credit: Scott Graham on Unsplash

Handling events in React is very close to how the events on DOM elements are handled. There are a few minor differences, for example, the event names follow the camel case convention whereas in DOM they are all lowercase; the function itself is passed as the event handler instead of its name in a string form. The biggest difference is, however, that React wraps the native DOM events into SyntheticEvent, making them behave slightly differently than the native events. React's documentation provides a detailed explanation of ins and outs of synthetic events. Consequently, typing form events in React is not the same as 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 component as well as discuss the most common pitfalls.

Let's use this simple signup form as an example:

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 the form data and send it to a server, there are two main options. The first is to keep the form uncontrolled and get the data in the onSubmit callback, and the second is to store the data on the form's state and send it on the form submit. We'll consider both approaches here.

Uncontrolled form

To get the form data on submit, we'll add onSubmit callback and retrieve the data from each element via its name property.

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 simplicity's sake, we omit the data validation and error handling here (as well as CSS styling). It's important to call event.preventDefault(); first, because the form submit will reload the page before we can collect the data. Returning false from the callback will not work here, which is one of the differences between synthetic events and native DOM events. To collect the form data we can either directly access it from the event's target by the element's id, or the target's elements property, e.g. both email: event.target.email.value and email: event.target.elements.email.value work. It is also possible to use the name attribute to get the form element's value, but since we already have ids set up (to enable connecting our inputs to their labels via the htmlFor attribute), we'll use the id here.

You'll notice that the event's type is any meaning there's no type checking for now. To fix that we'll need to define the type of the event for the onSubmit callback. For synthetic events, we'll use the type definitions provided by React. The first choice would be to use React.FormEvent with the HTMLFormElement type argument. This approach, while usually correct, doesn't work for our form because we want to retrieve data from the target attribute, which gets the generic EventTarget type. This is because 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. It seems like to fix our type issue we just need to switch from event.target to event.currentTarget.

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 almost works and would work properly if we didn't have an input element with id name in the form. As it currently stands, our currentTarget.name overrides the name attribute of the HTMLFormElement. However, there's a bigger issue with this approach and that is the absence of proper type checking of the target attributes. For example, we could try to access a non-existing form element via address: target.address.value and it wouldn't be caught by TS because it doesn't know what elements are there in the form. It would be better if we could define the id - element pairs and type the current target accordingly. To do that we need to look into the type definition of HTMLFormElement. We can see that it has readonly elements: HTMLFormControlsCollection;, with handy documentation - Retrieves a collection, in source order, of all controls in a given form. This is the event.target.elements attribute we talked about earlier, except in this case it's on the currentEvent.

Connecting everything, it seems like we can extend HTMLFormControlsCollection with our form elements and then overwrite HTMLFormElement.elements with that interface.

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 values of the form elements.

Controlled form

Although it is useful to know how to properly type uncontrolled form's event handlers, this kind of form is not very common in React components. Most of the time we want the form to have controlled elements, the values/callbacks for which 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. Luckily typing such event handlers is more straightforward than the form example above.

As an example, let's save the values entered by a user onto the state and then send them via 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).

const [state, setState] = useState({
  name: "",
  email: "",
  password: "",
  confirmPassword: "",
  conditionsAccepted: false,
});

It's often preferable to use the useReducer hook to handle complex state, however, 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, e.g.:

 <input
  id="name"
  onChange={(event) => setState({ ...state, name: event.target.value })}
/>

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 ok to have a separate handler for each element type, e.g. 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 checkbox field base on the target's type.

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>
  );
};

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 onChange event. Initially, we try to type the event as React.ChangeEvent, however that doesn't seem sufficient as we get a type error that all the properties we're trying to access do not exist on the event's target. Looking into the definition for ChangeEvent, we can see that it accepts a type argument, which defaults to generic Element. Because all the form's elements are inputs, we'll pass this type to ChangeEvent - 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 happens because when we declare value with let value = event.target.value; TS infers its type to be string, which is the type for the input's value. To fix this we need to let TS know that our value can also be a boolean: let value: string | boolean = event.target.value;. This works nicely, however, wouldn't it be better if TS could automatically infer the type of value based on the state? For example, let's say we add a new field age, which is input with a type number. To save it onto state, we'd add this block to onFieldChange

else if (event.target.type === "number") {
    value = parseInt(event.target.value, 10);
}

To get rid of the TS error we'd have to update the type of value to boolean | string | number. But what if we eventually decided to remove the age field? We'd also need to remember to update value types again. We could define a separate State type, where we declare the types for all the fields, however, there's a better way. We already have the types of the 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 that will automatically keep them in sync in case the data structure changes. To infer the types from the state we can use a neat TS notation - let value: typeof state[keyof typeof state] = event.target.value;. Here we're telling TS that we expect value to be the type of the values present on the state and if in the future any of them change, those changes will be automatically reflected in the type of value. The final code for the form looks like this:

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>
    );
};