Command Palette

Search for a command to run...

Hectal
PHASE 14Intermediate ~24 min· topic 3 of 6

Topic 14.3

Typing Event Handlers and Forms

In one line

React wraps browser events in its own event types, with the element baked in: ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>, SubmitEvent<HTMLFormElement>. Pick the right one and event.target.value is typed; pick the wrong one and the compiler tells you.

Think of it like this

A doorbell camera that labels each visitor: "Delivery person at the front door", "Neighbour at the side door". The label tells you who is there and which door they used before you open it. An event type is that label. ChangeEvent<HTMLInputElement> says "something changed, and the thing that changed is an <input>", so you may read .value. A plain Event only says "something happened somewhere".

Words you'll meet

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

Event
A message from the browser that something happened: a click, a key press, a change, a form being sent.
Synthetic event
React's wrapper around a browser event, so it behaves the same in every browser.
currentTarget
The element your handler is attached to.
target
The element that actually received the event, maybe a child of currentTarget.
Contextual typing
TypeScript working out a parameter's type from where the function is used, so you needn't write it.
Handler prop
A prop that holds a function the component calls when something happens, usually named onSomething.
FormData
A browser object that collects every field of a form. Its get returns string, File or null.
Controlled input
An input whose value comes from React state and whose onChange updates that state.
Parse at the edge
Checking and converting loosely typed data once, where it comes in, so the rest of the code uses precise types.

Step by step

01A controlled input

Keep the text in state. Show it in value, update it in onChange. The handler reads event.target.value, which is a string.

When you extract the handler, annotate its parameter with the element it will be attached to.

NameField.tsxwhole filetsx
function handleName(event: ChangeEvent<HTMLInputElement>) {
  setName(event.target.value);
}

<input value={name} onChange={handleName} />

02Inline handlers are free

Write the function right inside the tag and TypeScript fills in event. Hover it in the first example: for a <select> it is a ChangeEvent<HTMLSelectElement> without a single annotation from you.

03target versus currentTarget

In the third example a click on a <ul> could come from any <li> inside. event.currentTarget is the <ul> (typed EventTarget & HTMLUListElement). event.target is only EventTarget.

To read a data- attribute from the clicked item, narrow with instanceof HTMLLIElement first. After the check TypeScript knows picked.dataset exists.

target versus currentTargetdiagram
Rendering diagram…

04Handler props: pass the value, not the event

SearchBox takes onChange: (value: string) => void. Inside, an extracted ChangeEventHandler<HTMLInputElement> reads the event and calls onChange(event.target.value).

Callers don't need ChangeEvent at all. Offer the event as a second parameter only when callers truly need it.

05A submit handler and typed form data

Type the handler's event as SubmitEvent<HTMLFormElement>, call preventDefault(), then build a FormData from event.currentTarget.

Pass it to one parsing function that checks every field and returns SignUpData | null. The rest of the program never touches FormDataEntryValue.

SignUp.tsxwhole filetsx
function readSignUp(form: FormData): SignUpData | null {
  const email = form.get("email"); // string | File | null
  const age = Number(form.get("age"));
  if (typeof email !== "string" || email === "" || Number.isNaN(age)) {
    return null;
  }
  return { email, age };
}

Try it yourself

  1. 1

    Watch the hover change

    In the first example, add onClick={(event) => {}} with a // ^? under event to the <select>. You get MouseEvent<HTMLSelectElement>. Same element, different event type.

  2. 2

    Remove the narrowing

    In the third example replace if (picked instanceof HTMLLIElement) { with if (true) { and read the error on picked.dataset. Then put the check back and hover picked inside the block.

  3. 3

    Break the parser

    In the second example, change typeof email !== "string" to email === null and hover email below the check with a // ^?. It is still string | File, and return { email, age } now fails with Type 'FormDataEntryValue' is not assignable to type 'string'. Why is the first check the right one?

Code & diagrams

Controlled inputs: ChangeEvent for input and select

handleName needed its annotation because it lives outside the tag. The inline select handler did not: its event type came from the select's onChange. Type in the input to see the greeting change.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Hello, stranger (medium)
A submit handler, SubmitEvent and typed form data

form.get("email") is string | File | null until readSignUp checks it. After that, onValid receives a plain { email: string; age: number }. The script fills the fields and submits the form so the Console shows the parsed object.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

{ email: 'asha@example.com', age: 21 }
Handler props, and target versus currentTarget

SearchBox hides the event: its parent gets a plain string. In TagList, target is a bare EventTarget until instanceof narrows it, while currentTarget is already the list. The script clicks the first item.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

picked react

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

Use the target without narrowing it

In the handler props example, replace if (picked instanceof HTMLLIElement) { with if (true) {.

terminal
$ Types panel
── what you'll see ──
index.tsx(31,25): error TS2339: Property 'dataset' does not exist on type 'EventTarget'.

Break #2

Annotate a handler for the wrong element

In the controlled input example, change the handler's parameter type to ChangeEvent<HTMLSelectElement> but leave it on the <input>.

terminal
$ Types panel
── what you'll see ──
index.tsx(15,37): error TS2322: Type '(event: ChangeEvent<HTMLSelectElement, Element>) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.
Types of parameters 'event' and 'event' are incompatible.
Type 'ChangeEvent<HTMLInputElement, HTMLInputElement>' is not assignable to type 'ChangeEvent<HTMLSelectElement, Element>'.
Type 'HTMLInputElement' is missing the following properties from type 'HTMLSelectElement': length, options, selectedIndex, selectedOptions, and 4 more.

Myth vs fact

Myth

The type for every React event is React.FormEvent.

Fact

Each event has its own type: ChangeEvent for changes, MouseEvent, KeyboardEvent, SubmitEvent for submits. FormEvent is deprecated in @types/react 19.

Myth

event.target and event.currentTarget are the same.

Fact

currentTarget is the element with the handler. target is whatever was actually clicked or changed, which can be a child element.

Myth

A number input gives a number.

Fact

event.target.value is always a string, for every input type. Convert with Number(...) or read valueAsNumber, and check for NaN.

Pro corner

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

  • ▸

    React's handler types use a trick called the bivariance hack: EventHandler<E> is declared as { bivarianceHack(event: E): void }["bivarianceHack"], a method signature, so that under strictFunctionTypes (Topic 7.7) a handler written for a *narrower* event (say MouseEvent<HTMLButtonElement>) can still be passed where MouseEventHandler<HTMLElement> is expected. Method parameters are checked bivariantly, function-typed properties are not: a plain (e: MouseEvent<HTMLButtonElement>) => void is rejected there. It is a deliberate soundness hole, and it does not save you when the two elements are unrelated (see Break it).

  • ▸

    ChangeEvent<CurrentTarget, Target> has two parameters in @types/react 19.3, but its target is still EventTarget & CurrentTarget; the comment in the types says it is kept for backwards compatibility until React 20. Don't rely on the second parameter.

  • ▸

    In setForm({ ...form, [event.target.name]: event.target.value }) the computed key is a plain string, so TypeScript cannot check that it names a real field: a misspelt name="emial" in the JSX compiles and adds a stray property at runtime. For forms with many fields prefer either FormData parsed once on submit, or one onChange per field.

  • ▸

    The browser's own addEventListener uses typed event maps (Topic 13.2); React's on* props are separate and typed through DOMAttributes<T>. If you call element.addEventListener inside an effect you are back in the DOM types, not React's.

Remember this

  1. 1

    The problem this solves: an event handler receives an object, and in plain JavaScript you guess what is on it, so event.target.value on the wrong element fails only when someone clicks. Event types tell the compiler which element and which fields exist. An event is a message the browser sends when something happens: a click, a key press, typing, a form being sent. React passes your handler a synthetic event (React's own wrapper, SyntheticEvent) that behaves the same in every browser. The types in @types/react describe those wrappers. Each takes the element type as a type argument: MouseEvent<HTMLButtonElement>, KeyboardEvent<HTMLInputElement>, ChangeEvent<HTMLSelectElement>.

  2. 2

    Two targets, two meanings. event.currentTarget is the element your handler is attached to, always exactly the element type in the type argument. event.target is the element that actually started the event, which for a click might be a child of the button (the <span> inside it). That is why a MouseEvent's target is only an EventTarget: TypeScript can't know how deep the user clicked. To use it as an element you must narrow it (Topic 4.4): if (event.target instanceof HTMLLIElement). The one exception is ChangeEvent, whose target React types as the form element itself, because form fields can't contain other fields.

  3. 3

    Inline handlers need no annotation. <input onChange={(event) => ...} /> is contextually typed (Topic 2.4): TypeScript knows onChange expects a ChangeEventHandler<HTMLInputElement>, so event is a ChangeEvent<HTMLInputElement> without you writing it. When you pull the handler out into its own function, nothing tells TypeScript what it will be used for, so you annotate the parameter yourself: function handleName(event: ChangeEvent<HTMLInputElement>). Put the wrong element there and passing it to the wrong tag is an error (see Break it).

  4. 4

    Handler props. When your own component accepts a callback, you choose its type. The cleanest is to hand over the useful value and hide the event: onChange: (value: string) => void. The parent then needs no knowledge of DOM events, and tests are easier. When a parent genuinely needs the event (to call preventDefault, to read a modifier key), pass it as a second parameter, or type the prop with React's alias ChangeEventHandler<HTMLInputElement>, a ready-made function type for exactly this.

  5. 5

    Forms. onSubmit receives a SubmitEvent<HTMLFormElement>. Its target and currentTarget are the form, and submitter is the button that started the submit, or null when there was none (for example when code called form.requestSubmit() with no argument). You will also see FormEvent<HTMLFormElement> in older code and tutorials. It still compiles, but @types/react 19 marks it deprecated, because "form event" is not a real browser event; prefer SubmitEvent for submit and ChangeEvent for changes. Call event.preventDefault() first, or the browser reloads the page.

  6. 6

    Typed form data. new FormData(event.currentTarget) gives you an object of everything in the form, but form.get("email") has type FormDataEntryValue | null, i.e. string | File | null. TypeScript can't know that your input holds text. The honest pattern is to parse at the edge: write one function, readSignUp(form: FormData): SignUpData | null, that checks each value (typeof email !== "string") and returns a properly typed object or null. Everything after that works with SignUpData and never sees FormDataEntryValue again. The same idea fixes numbers: an <input type="number"> still gives text, so convert with Number(...) and check Number.isNaN.

  7. 7

    Controlled inputs need two matching types. The state and the value prop: useState("") is a string, value={name} is a string. The event: event.target.value is always a string, even for a number input; for a checkbox read event.target.checked (a boolean) and for a number input event.target.valueAsNumber (a number, NaN when empty). If your state is a number, you must convert; TypeScript will make you.

Explain it without notes

01

How do you type an onChange handler for an input, and where does the type of event.target.value come from?

02

What is the difference between event.target and event.currentTarget in React's types?

03

How do you type a callback prop like onChange on your own component, and why prefer the value over the event?

04

Why is FormData.get() not typed as string, and how do you deal with it?

Practice

01

Write a TodoForm with one text input (name title) and an onAdd: (title: string) => void prop. On submit it must call onAdd with the trimmed title only if it is not empty.

02

Write a Checkbox component with props checked: boolean and onChange: (checked: boolean) => void that renders <input type="checkbox">. Make sure the parent never sees an event.

Trade-offs

  • ↔

    Passing a value ((value: string) => void) decouples parents from the DOM, but loses preventDefault and modifier keys. Offer the event as a second argument for the few components that need it.

  • ↔

    Parsing FormData by hand is explicit and dependency-free, but repeats for every form. When you have more than a handful of forms, a schema library (Topic 13.4) pays for itself.

Done when you can

  • I can type a change handler for an input, a select and a checkbox.

  • I can say what target and currentTarget are and narrow target.

  • I know when an inline handler needs no annotation and when an extracted one does.

  • I can type a callback prop and choose between the value and the event.

  • I can type a submit handler and parse FormData into a typed object.