Contents
19 Chapter

Typing events and forms

ChangeEvent · FormEvent · KeyboardEvent and the types of input handlers, TypeScript patterns for controlled and uncontrolled forms, and the foundation for Chapter 27's Server Actions FormData.

In Chapter 18 we covered the types of built-in hooks. This chapter is about the typing you meet most often inside a component — event objects and form inputs.

We put the patterns from Chapter 6 (Event handling) and Chapter 9 (Handling forms) back on top of TypeScript. And the new model you will meet in Chapter 27 (Server Actions and forms) — <form action={fn}> + FormData — extends almost directly from the uncontrolled form pattern in this chapter. Get comfortable with FormData here and Chapter 27 reads far more lightly.

In JavaScript you would just write e.target.value and move on. In TypeScript you first have to decide what type e is. Let’s look at how to make that decision cleanly.

React event object types — React.XXXEvent #

React’s synthetic event objects are all based on React.SyntheticEvent, with narrower types per event kind and target element. The five you use most are:

EventType
onClickReact.MouseEvent<HTMLButtonElement>
onChange (input)React.ChangeEvent<HTMLInputElement>
onSubmit (form)React.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLInputElement>
onFocus / onBlurReact.FocusEvent<HTMLInputElement>

The type argument slot is the element the event fires on. This has to be in place for e.currentTarget to be narrowed correctly.

common event typing
function NameInput() {
  const [name, setName] = useState('');

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);  // auto-inferred as string
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      // ...
    }
  };

  return <input value={name} onChange={onChange} onKeyDown={onKeyDown} />;
}

e.target vs e.currentTarget #

This is a spot in React + TypeScript that trips people up more than you would expect.

  • e.currentTarget — the element the event handler is attached to. The type is exact.
  • e.target — the element the event started from. If a child is clicked, it is the child.

If you attach onClick={...} to a parent and read e.target.value, a child might come through and the types do not fit. When reading an input’s value, the answer is almost always e.currentTarget.value.

use currentTarget
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  // both work, but
  console.log(e.target.value);          // EventTarget — narrowed, but the meaning is ambiguous
  console.log(e.currentTarget.value);   // HTMLInputElement — safer
};

For onChange, target is narrowed too, but with delegation patterns like buttons or lists, currentTarget is the only correct answer. When in doubt, going with currentTarget is always safe.

ItemTypeMeaning
e.currentTargetT (type argument)the element the handler is attached to
e.targetEventTargetthe element the event actually started on (a child, with delegation)

Inline handlers — when you do not have to write parameter types #

A handler written inline in JSX does not need parameter types — they get inferred. The parent prop type (onChange) hands the function signature down.

inline — leave it to inference
<input
  onChange={(e) => setQuery(e.target.value)}  // e is auto-inferred
/>

Inline is clean when the handler is short. When it gets longer, pull it out into the component body and at that point write (e: React.ChangeEvent<HTMLInputElement>) => ... explicitly. Being able to move between the two patterns freely is enough.

Controlled forms #

Start with the most common pattern. Keep the input value in state and call the setter on every keystroke.

controlled input — single field
import { useState } from 'react';

function NameForm() {
  const [name, setName] = useState('');

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    alert(`Hello, ${name}!`);
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

TypeScript catches two things here.

  1. In setName(e.target.value), value is always string. setName accepts only string, so it is safe.
  2. e: FormEvent<HTMLFormElement> in onSubmit autocompletes e.preventDefault().

Bundling many fields into one object #

When fields grow in number, calling useState for each is tedious. A single object state + a shared onChange is the common answer.

multiple fields — as an object
type SignupForm = {
  email: string;
  password: string;
  agree: boolean;
};

function SignupPage() {
  const [form, setForm] = useState<SignupForm>({
    email: '',
    password: '',
    agree: false,
  });

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value, type, checked } = e.currentTarget;
    setForm((prev) => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  };

  return (
    <form>
      <input name="email" value={form.email} onChange={onChange} />
      <input name="password" type="password" value={form.password} onChange={onChange} />
      <input name="agree" type="checkbox" checked={form.agree} onChange={onChange} />
    </form>
  );
}

The key is using the name attribute as the key into prev. The cost is that type safety gets a bit loose. A typo from name="email" to name="emial" is not caught by the compiler. Once a form grows, leaning on the library help covered later in this chapter is safer.

Uncontrolled forms — using FormData #

If your form is purely for submission, you do not need a setState on every keystroke. The pattern of reading everything at submit time with FormData is lighter and pairs naturally with React 19’s Server Actions.

uncontrolled — read with FormData at once
function ContactForm() {
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get('email');     // FormDataEntryValue | null
    const message = formData.get('message');

    if (typeof email !== 'string' || typeof message !== 'string') return;

    // safely narrowed to string before use
    sendMessage({ email, message });
  };

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

The return type of formData.get('email') is FormDataEntryValue | null. FormDataEntryValue is string | File, so when working with a text input, make it a habit to narrow once with typeof === 'string'.

The upside of uncontrolled forms is that the code stays clean. The downside is that reacting to input on the fly (character count, real-time validation) is harder. A simple submit form goes uncontrolled, a form needing instant feedback goes controlled is the usual guide.

Bridge to Chapter 27 Server Actions #

This uncontrolled pattern leads directly into the new model in Chapter 27 (Server Actions and forms). Inside React 19’s <form action={serverFn}>, the onSubmit + preventDefault cycle disappears and a server function takes formData: FormData directly.

preview of the model in Chapter 27
'use server';

async function sendContactAction(formData: FormData) {
  const email = formData.get('email');
  const message = formData.get('message');

  if (typeof email !== 'string' || typeof message !== 'string') {
    return { error: 'invalid input' };
  }

  // save to DB / send email on the server
  await saveContact({ email, message });
}

// client
<form action={sendContactAction}>
  <input name="email" type="email" required />
  <textarea name="message" required />
  <button type="submit">Send</button>
</form>

FormData and the typeof === 'string' narrowing pattern stay alive. The uncontrolled model you picked up here carries straight through into the new model in Part 4.

FormEvent.currentTarget.elements — fetch by name #

In an uncontrolled form, instead of FormData you can fetch values by name like e.currentTarget.elements.email. TypeScript does not know which inputs are inside the form, though, so you need two steps.

fetching via elements
type FormElements = HTMLFormControlsCollection & {
  email: HTMLInputElement;
  message: HTMLTextAreaElement;
};

type ContactFormElement = HTMLFormElement & {
  readonly elements: FormElements;
};

function ContactForm() {
  const onSubmit = (e: React.FormEvent<ContactFormElement>) => {
    e.preventDefault();
    const email = e.currentTarget.elements.email.value;       // string
    const message = e.currentTarget.elements.message.value;   // string
    sendMessage({ email, message });
  };

  return (
    <form onSubmit={onSubmit}>
      <input name="email" />
      <textarea name="message" />
      <button type="submit">Send</button>
    </form>
  );
}

The types are exact, but the boilerplate is heavy. For one or two forms, FormData fits. When many forms share the same shape, the elements approach is a better match.

When a form gets big, consider a library #

Once you cross five or six fields, the cost of maintaining types by hand grows fast. The libraries you see most often in practice are:

  • react-hook-form + zodregister / handleSubmit catch types almost automatically. Defining validation and types in a single zod schema is a popular pattern. We meet zod again in Chapter 21 (Typing fetch and API responses).
  • Server Actions + zod (Next.js) — keep the form uncontrolled and validate on the server. The client side hardly writes any code. This is the Chapter 27 model.

The main text of this book sticks to built-ins without libraries. In real projects, picking one of these two up front saves time.

The return type of a submit handler #

If you write the submit handler as async, the return type becomes Promise<void>. The JSX onSubmit prop accepts both shapes.

async onSubmit
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  await fetch('/api/contact', { method: 'POST', body: formData });
};

<form onSubmit={onSubmit}>...</form>  // OK

One thing to watch when going async — call e.preventDefault() before any await. Past await, the form may have already been submitted and a page navigation may have started.

Try it yourself #

Let’s rewrite the core of the signup form from Chapter 9 in TypeScript, using the FormData-based uncontrolled pattern.

src/SignupForm.tsx:

src/SignupForm.tsx
import { useState } from 'react';

type SubmittedData = {
  name: string;
  email: string;
  agreed: boolean;
};

function SignupForm() {
  const [submitted, setSubmitted] = useState<SubmittedData | null>(null);
  const [error, setError] = useState<string | null>(null);

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const name = formData.get('name');
    const email = formData.get('email');
    const agreed = formData.get('agreed');

    if (typeof name !== 'string' || typeof email !== 'string') {
      setError('Please enter both name and email.');
      return;
    }
    if (agreed !== 'on') {
      setError('Please agree to the terms.');
      return;
    }

    setError(null);
    setSubmitted({ name, email, agreed: true });
  };

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>Sign up</h2>
      <form onSubmit={onSubmit}>
        <div>
          <label>Name: </label>
          <input name="name" required />
        </div>
        <div>
          <label>Email: </label>
          <input name="email" type="email" required />
        </div>
        <div>
          <label>
            <input name="agreed" type="checkbox" />
            I agree to the terms
          </label>
        </div>
        <button type="submit" style={{ marginTop: '8px' }}>Sign up</button>
      </form>

      {error && <p style={{ color: 'red', marginTop: '8px' }}>{error}</p>}
      {submitted && (
        <pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
          {JSON.stringify(submitted, null, 2)}
        </pre>
      )}
    </div>
  );
}

export default SignupForm;

Save and confirm it works. Filling in name, email, and the checkbox and submitting shows the result as JSON. Fill in something wrong and a red error message appears.

The trap point for the checkbox is that FormData.get('agreed') returns the string 'on' when checked and null when not. The code above narrows it with agreed !== 'on'.

Exercises #

  1. Write the controlled version of SignupForm above too. Bundle into one object with useState<{ name: string; email: string; agreed: boolean }> and handle a shared onChange. Compare which code is shorter and which is safer.
  2. Meet the difference between currentTarget and target directly. Attach a handler to a parent <div onClick={...}> with a <button> inside. Click the button and confirm in the console that e.target is the button and e.currentTarget is the div. Also notice that target’s type is widely EventTarget, so access like .value is blocked.
  3. Compare FormData with the Chapter 27 Server Actions model. Pull the code inside the onSubmit of SignupForm above into a separate function submitSignup(formData: FormData), and have onSubmit call it after e.preventDefault(). The signature ends up almost identical to the Server Action you will meet in Chapter 27.

In one line: Event types take the form React.XXXEvent<element>. When reading an input value, e.currentTarget.value is safe. Leave inline handlers to inference; pull them out and write the type explicitly. Single-field controlled forms use one useState; multi-field forms use an object + name. Uncontrolled forms read cleanest with FormData and carry straight into the Chapter 27 Server Actions model. The result of formData.get() is FormDataEntryValue | null, so narrow once with typeof === 'string'. Once a form grows, reach for libraries like react-hook-form + zod.

Next chapter #

In the next Chapter 20 Context and generic components we put the JavaScript patterns from Chapter 12 (useContext) back on top of TypeScript. The type-argument pattern for createContext, a safe useContext helper, generic patterns for reusable components like List and Select, and the as prop of polymorphic components, all in one chapter.

X