TypeScript: Typing form events in React
Tags: typescript, react, forms, webdev
Published: 2022-09-05 18:00:11

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](https://reactjs.org/docs/events.html) 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:
```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 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.
```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 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`.
```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 _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.
```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 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).
```tsx
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.:
```tsx
 <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.
```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>
  );
};
```
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`:
```tsx
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:
```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>
    );
};
```