Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~26 min· topic 4 of 6

Topic 12.4

Typing Events and Handlers

In one line

Inline handlers get their event type for free; separate handler functions use React's event types such as ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement> and FormEvent<HTMLFormElement>, and event.currentTarget is the typed element the handler is attached to.

Think of it like this

A doorbell and a parcel. When the bell rings, you know which door it is (your door: the one with the bell on it), but not who pressed it or what they're holding. React events are similar: currentTarget is the element your handler is on, so TypeScript knows exactly what it is; target is whatever was really clicked, which could be anything inside, so TypeScript only knows it is "some event target".

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Event handler
A function React calls when something happens, like onClick or onChange.
Synthetic event
React's wrapper around the browser's event. It has the same main properties, plus nativeEvent.
Contextual typing
TypeScript giving an inline function its parameter types from where it is written.
ChangeEvent
React's type for onChange events. ChangeEvent<HTMLInputElement> has target.value as a string.
MouseEvent
React's type for click and mouse events, generic on the element: MouseEvent<HTMLButtonElement>.
FormEvent / SubmitEvent
Types for onSubmit. FormEvent is the classic one; newer types prefer SubmitEvent.
currentTarget
The element the handler is attached to. Its type is known exactly.
target
The element where the event started. It may be a child, so its type is vague.
MouseEventHandler
A shortcut type for a function that takes a MouseEvent and returns nothing.

Step by step

01Start inline, hover to learn the type

Inline arrow functions need no annotation. Hover over e in VS Code: it shows ChangeEvent<HTMLInputElement, HTMLInputElement>. Copy that when you move the handler out.

Name.tsxwhole filetsx
<input value={name} onChange={(e) => setName(e.target.value)} />  // e is typed for you

02Annotate separate handlers

Moved out of JSX, the function loses its context. Import the event type from react and give the element type.

Name.tsxwhole filetsx
import type { ChangeEvent, KeyboardEvent, MouseEvent, FormEvent } from "react";

function handleChange(e: ChangeEvent<HTMLInputElement>) { setName(e.target.value); }
function handleKey(e: KeyboardEvent<HTMLInputElement>) { if (e.key === "Enter") save(); }
function handleClick(e: MouseEvent<HTMLButtonElement>) { console.log(e.clientX); }
function handleSubmit(e: FormEvent<HTMLFormElement>) { e.preventDefault(); }

03Recognise the four classic mistakes

These are the real messages. The last one comes from forgetting to import MouseEvent from react, so the global DOM MouseEvent is used instead.

App.tsxwhole filetsx
import type { MouseEvent as ReactMouseEvent } from "react";

function handleChange(e) {
// error TS7006: Parameter 'e' implicitly has an 'any' type.
  console.log(e.target.value);
}

function handleClick(e: ReactMouseEvent<HTMLButtonElement>) {
  console.log(e.target.value);
// error TS2339: Property 'value' does not exist on type 'EventTarget'.
}

function handleDomClick(e: MouseEvent) {  // the DOM's MouseEvent, not React's
  console.log(e.clientX);
}

<input onChange={handleClick} />
// error TS2322: Type '(e: MouseEvent<HTMLButtonElement, MouseEvent>) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.
//   Types of parameters 'e' and 'event' are incompatible.
//     Type 'ChangeEvent<HTMLInputElement, HTMLInputElement>' is missing the following properties from type 'MouseEvent<HTMLButtonElement, MouseEvent>': altKey, button, buttons, clientX, and 14 more.

<button onClick={handleDomClick}>Go</button>
// error TS2322: Type '(e: MouseEvent) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
//   Types of parameters 'e' and 'event' are incompatible.
//     Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is missing the following properties from type 'MouseEvent': layerX, layerY, offsetX, offsetY, and 16 more.

04Use currentTarget

currentTarget is the element the handler is on, with its exact type. target may be a child. Read values from currentTarget.

Use currentTargetdiagram
Rendering diagram…

05Type handler props

For your own components, a callback that passes data is usually best. For a low-level component that wraps an element, pass the event through with a ...Handler type.

SongRow.tsxwhole filetsx
import type { ComponentProps, MouseEventHandler } from "react";

type SongRowProps = {
  onSelect: (id: number) => void;                     // domain callback
  onRemove?: MouseEventHandler<HTMLButtonElement>;    // DOM-style, optional
  onPlay?: ComponentProps<"button">["onClick"];        // borrowed from <button>
};

06One handler for many fields

Give each field a name and read e.currentTarget.name and .value. A union of element types lets the same handler serve inputs, selects and textareas.

Signup.tsxwhole filetsx
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) {
  const { name, value } = e.currentTarget;
  setForm((f) => ({ ...f, [name]: value }));
}

Try it yourself

  1. 1

    Clear with Enter

    In the form example, change the key handler so Enter in the name field logs enter pressed and calls e.preventDefault(). Predict: does pressing Enter still submit the form? (No: preventDefault on the keydown stops the implicit submit.) Try it by typing in the field.

  2. 2

    Read target instead

    In the song example, replace e.currentTarget.tagName with e.target.tagName (remove the cast). The preview still runs. What would tsc say? (Property 'tagName' does not exist on type 'EventTarget'.) Why is that a fair complaint?

  3. 3

    Give every row a Remove button

    Pass onRemove for every song. Then remove e.stopPropagation() and predict the console when Remove is clicked: two lines, remove clicked... and then selected song 2, because the click bubbles to the <li>.

Code & diagrams

A typed form: ChangeEvent, KeyboardEvent and FormEvent New tab

The script fills the fields and submits. Try it yourself too: type a name, press Escape to clear it, then submit. One `handleChange` serves all three fields because its event type is a union of the three element types. Caution: the computed key `[name]` is a plain `string`, so TypeScript can't check that `plan` only receives `"free"` or `"pro"`; computed keys are a hole in the types.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

submit from <form>: { name: 'Asha', plan: 'pro', bio: 'I like maps' }
currentTarget vs target, and two styles of handler props New tab

Clicking the title `<span>` still runs the `<li>` handler (the event bubbles up), so `target` is `SPAN` while `currentTarget` is `LI`. That's why `target` is typed only as `EventTarget` and needs a cast to read `tagName`. The Remove button stops the event, so the row isn't selected too. Inside the inline `onRemove` arrow, `e` is typed for you from `MouseEventHandler<HTMLButtonElement>`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

currentTarget: LI | target: LI
selected song 1
currentTarget: LI | target: SPAN
selected song 1
remove clicked, button text: Remove
Newer event types in @types/react 19.x: SubmitEvent and handler aliasestsx
import type { SubmitEvent, ChangeEventHandler } from "react";

// FormEvent still works but is marked deprecated in these types; SubmitEvent is more precise.
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
  e.preventDefault();
  console.log(e.submitter?.textContent, e.currentTarget.elements.length);
}

// A handler alias types the whole function, so (e) needs no annotation.
const onName: ChangeEventHandler<HTMLInputElement> = (e) => console.log(e.target.value);

<form onSubmit={handleSubmit}>
  <input onChange={onName} />
</form>
// tsc: no errors

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget to import MouseEvent from react

Write function handleClick(e: MouseEvent) { ... } with no import and use it as <button onClick={handleClick}>.

terminal
$ npx tsc --noEmit
── what you'll see ──
error TS2322: Type '(e: MouseEvent) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
Types of parameters 'e' and 'event' are incompatible.
Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is missing the following properties from type 'MouseEvent': layerX, layerY, offsetX, offsetY, and 16 more.

Break #2

Read value from target on a click

In a button's click handler, write setChoice(e.target.value).

terminal
$ npx tsc --noEmit
── what you'll see ──
error TS2339: Property 'value' does not exist on type 'EventTarget'.

Myth vs fact

Myth

You must annotate every event parameter.

Fact

Inline handlers in JSX are contextually typed. Annotate only functions declared separately, or type the variable with a handler alias like ChangeEventHandler<HTMLInputElement>.

Myth

e.target and e.currentTarget are the same thing.

Fact

They are the same only when the event started on the element itself. target is where it started (maybe a child); currentTarget is where the handler is. Their types differ for that reason.

Myth

React events are the browser's events.

Fact

They are synthetic wrappers with the same main API. Their TypeScript types are different from the DOM's types with the same names; the browser's event is on e.nativeEvent.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    In @types/react 19.x, ChangeEvent<CurrentTarget, Target> has two type parameters and onChange on <input> is ChangeEventHandler<T, HTMLInputElement>. That's why hovers show ChangeEvent<HTMLInputElement, HTMLInputElement>. Writing ChangeEvent<HTMLInputElement> still works because the second parameter has a default.

  • ▸

    React's EventHandler<E> type is declared with a "bivariance hack" ({ bivarianceHack(event: E): void }["bivarianceHack"]), so handler parameters are compared in both directions, looser than strictFunctionTypes: a handler for MouseEvent<HTMLElement> fits a button, and so would one for a more specific element. Unrelated elements still fail: a MouseEvent<HTMLButtonElement> handler on a <div> gives ... is not assignable to type 'MouseEventHandler<HTMLDivElement>'. In your own prop types, property-style onPick: (id: number) => void is checked strictly; method-style onPick(id: number): void is bivariant.

  • ▸

    A prop typed () => void accepts () => number; the result is ignored. That's why onClick={() => setCount(c + 1)} is fine even though some setters return values. But it also means TypeScript won't warn if you pass an async function whose returned promise rejects, so handle errors inside async handlers.

  • ▸

    React attaches one listener per event type at the root and dispatches synthetic events through the React tree (Topic 4.1), which is also why events bubble through portals to their React parent. e.stopPropagation() stops React propagation; the native event may already have reached listeners attached directly to the DOM.

Remember this

  1. 1

    Inline handlers are typed for you. In <input onChange={(e) => setName(e.target.value)} />, TypeScript knows onChange on an <input> takes a change handler, so it gives e the right type automatically. This is called contextual typing: the place a function is written decides its parameter types. Hover over e in your editor to see the type: that's the easiest way to learn the names.

  2. 2

    Separate handler functions need an annotation, because a function declared on its own has no context: function handleChange(e) is Parameter 'e' implicitly has an 'any' type. in strict mode. React's event types live in the react package and take the element type as a type argument: ChangeEvent<HTMLInputElement> for onChange on inputs (also HTMLSelectElement, HTMLTextAreaElement), MouseEvent<HTMLButtonElement> for onClick, KeyboardEvent<HTMLInputElement> for onKeyDown (e.key is "Enter", "Escape"...), FocusEvent<HTMLInputElement> for onBlur, and FormEvent<HTMLFormElement> for onSubmit. They are React's synthetic events: React's wrapper around the browser's event with the same main properties (preventDefault(), stopPropagation(), key, clientX...), and e.nativeEvent holds the browser's own event.

  3. 3

    **Import them from react.** The browser also has global types called MouseEvent, KeyboardEvent and FocusEvent (the DOM versions). If you forget the import, e: MouseEvent silently means the DOM type, and passing the handler to onClick fails with a long message that ends in is missing the following properties from type 'MouseEvent': layerX, layerY, offsetX, offsetY, and 16 more. Write import type { MouseEvent } from "react" (or use React.MouseEvent).

  4. 4

    **currentTarget versus target.** e.currentTarget is the element the handler is attached to, typed EventTarget & HTMLButtonElement (so .value, .disabled and friends are available). e.target is the element where the event started, which can be a child (a <span> inside a <li>), so for mouse events it is typed only as EventTarget and e.target.value is an error: Property 'value' does not exist on type 'EventTarget'. Prefer currentTarget. ChangeEvent is a special case: its target is typed as the element too, because change events come from the form field itself.

  5. 5

    Handler types for props. When a component accepts a handler, choose between two styles. A domain callback says what happened in your app's words and passes plain data: onSelect: (id: number) => void, onRename?: (name: string) => void. A DOM-style handler passes the event through: onClick?: MouseEventHandler<HTMLButtonElement> (a shortcut for (e: MouseEvent<HTMLButtonElement>) => void), or borrow it from the element: ComponentProps<"button">["onClick"]. Domain callbacks are easier to test and reuse; DOM-style ones fit low-level components like Button. Optional callbacks are called with onRename?.(name). A => void return type also accepts functions that return something; the value is ignored.

  6. 6

    Version note. In the @types/react 19.x types installed here, FormEvent and FormEventHandler are marked deprecated, because the browser has no "form event": the types now offer SubmitEvent<HTMLFormElement> for onSubmit (with e.submitter, the button that submitted), InputEvent for onInput, and ChangeEvent for onChange. FormEvent<HTMLFormElement> still compiles everywhere (you'll see it in most code and docs), and your editor shows it struck through. With React 19 form actions (<form action={fn}>, Phase 11) you often don't need a submit event at all.

Explain it without notes

01

Why don't inline handlers need event types, but separate handler functions do?

02

What's the difference between e.target and e.currentTarget, and why are they typed differently?

03

How would you type an onSelect prop and an onClick prop on your own components?

04

Why can e: MouseEvent fail to match onClick even though the names match?

Practice

01

Write a SearchInput with a separate handleChange typed with ChangeEvent<HTMLInputElement> and a handleKeyDown that logs search: <text> when Enter is pressed.

02

Write a Tabs component with props tabs: string[] and onChange: (index: number) => void, which renders a button per tab and calls onChange with the index.

Trade-offs

  • ↔

    Domain callbacks (onSelect(id)) hide DOM details and are easy to test, but callers can't preventDefault or read the event. DOM-style handlers give full control but tie callers to the markup. Use domain callbacks for app components and DOM-style for primitives.

  • ↔

    One generic change handler with [name]: value saves code but loses per-field type checking (the computed key is a string). For a few fields, separate handlers or a reducer keep the types exact.

Done when you can

  • I let inline handlers be typed by context and hover to see their types.

  • I can annotate separate handlers with ChangeEvent, MouseEvent, KeyboardEvent and FormEvent (or SubmitEvent).

  • I import event types from react, not the DOM globals.

  • I read values from currentTarget and know why target is vague.

  • I can type handler props as domain callbacks or with MouseEventHandler / ComponentProps<"button">["onClick"].