TypeScript + React in Practice #4: Typing events and forms

3 min read

In #3 Typing hooks we organized the types of built-in hooks. This post covers what you’ll meet most often inside components — event objects and form inputs.

In JavaScript, writing e.target.value was enough; in TypeScript, you first have to decide what type e is. Let’s see how to make those decisions cleanly.

React event object types — React.XXXEvent #

React’s synthetic event objects all derive from React.SyntheticEvent, with narrower types for each event kind and target element. The five most-used:

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

In the type argument slot, write the element where the event happens. With it, e.currentTarget’s type narrows 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 #

A surprisingly confusing area in React + TypeScript.

  • e.currentTarget — the element the handler is attached to. Its type is precise.
  • e.target — the element where the event started. If a child is clicked, it’ll be the child.

If you attach onClick={...} on the parent and read e.target.value, a child element may be the actual event target and the type won’t match. When reading an input’s value, e.currentTarget.value is almost always the answer.

Use currentTarget
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  // Both work
  console.log(e.target.value);          // EventTarget — narrowed but ambiguous in meaning
  console.log(e.currentTarget.value);   // HTMLInputElement — safer
};

For onChange, target is also narrowed; but for delegation patterns like buttons/lists, only currentTarget is correct. When in doubt, always go currentTarget.

Inline handlers — no need to annotate parameters #

For handlers written inline in JSX, you don’t have to annotate parameter types — they’re inferred. The parent prop type (onChange) tells TypeScript the function signature.

Inline — leave to inference
<input
  onChange={(e) => setQuery(e.target.value)}  // e's type is auto-inferred
/>

For short handlers, inline is clean. For longer ones, extract into the component body and annotate (e: React.ChangeEvent<HTMLInputElement>) => .... Being able to switch between the two is enough.

Controlled forms #

The most common pattern. Keep input values in state and call the setter on every change.

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

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

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

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

TypeScript catches two things here.

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

Bundling many fields into one object #

When fields multiply, calling useState repeatedly gets tedious. Object state + a generic onChange is the common answer.

Many 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 trick is using the name attribute as a key on prev. Note this approach loosens type safety slightly. A typo of name="email" to name="emial" won’t be caught by the compiler. As forms grow, leaning on a library covered next is safer.

Uncontrolled forms — using FormData #

If a form is just for submitting, you don’t need to setState on every input. Reading everything via FormData at submit time is light and pairs naturally with React 19 Server Actions.

Uncontrolled — read all at once with FormData
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 use after narrowing to string
    sendMessage({ email, message });
  };

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

formData.get('email') returns FormDataEntryValue | null. FormDataEntryValue is string | File, so for text inputs you should make a habit of narrowing once with typeof === 'string'.

The advantage of an uncontrolled form is clean code. The disadvantage is it’s hard to react instantly to input (character count, real-time validation). The general guide: uncontrolled for simple submit forms; controlled when you need instant feedback.

FormEvent.currentTarget.elements — get fields by name #

In an uncontrolled form, instead of FormData you can pull fields by name like e.currentTarget.elements.email. But TypeScript doesn’t know which inputs the form contains, so two steps are needed.

Pulling directly 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">보내기</button>
    </form>
  );
}

This approach is type-precise but boilerplate-heavy. For a couple of forms, FormData is better; if many forms share the same shape, the elements approach fits.

Consider a library when the form grows #

Past 5–6 fields, manually managing types gets expensive fast. In real work the following are common:

  • react-hook-form + zod — register/handleSubmit type things almost automatically. The pattern of defining validation and types together with a zod schema is popular.
  • Formik + yup — older combo. react-hook-form has better TypeScript support.
  • Server Actions + zod (Next.js) — keep the form uncontrolled and validate on the server. Almost no client-side code.

This series only covers built-ins, but in real projects, picking one of these three saves time.

Return type of submit handlers #

Writing the submit handler as async makes its return type Promise<void>. JSX’s 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 caveat for async — call e.preventDefault() before any await. If an await runs first, the form may already have submitted and navigation may have started.

Wrap-up #

This post covered:

  • Event types use the React.XXXEvent<Element> form
  • When reading an input value, e.currentTarget.value is safer
  • Leave inline handlers to inference; annotate when extracting to the component body
  • Controlled form: single useState for one field; object + name attribute for many
  • Uncontrolled form: FormData is the cleanest. Narrow once to string before use.
  • For larger forms, consider libraries like react-hook-form + zod

In the next post (#5 Context and generic components) we cover createContext type-argument patterns, a safe useContext helper, and how to build reusable components with generic components.

X