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
getreturnsstring,Fileornull. - Controlled input
- An input whose
valuecomes from React state and whoseonChangeupdates 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.
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.
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.
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
Watch the hover change
In the first example, add
onClick={(event) => {}}with a// ^?undereventto the<select>. You getMouseEvent<HTMLSelectElement>. Same element, different event type. - 2
Remove the narrowing
In the third example replace
if (picked instanceof HTMLLIElement) {withif (true) {and read the error onpicked.dataset. Then put the check back and hoverpickedinside the block. - 3
Break the parser
In the second example, change
typeof email !== "string"toemail === nulland hoveremailbelow the check with a// ^?. It is stillstring | File, andreturn { email, age }now fails withType 'FormDataEntryValue' is not assignable to type 'string'. Why is the first check the right one?
Code & diagrams
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
Console
Nothing logged yet.Expected console output
Hello, stranger (medium)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
Console
Nothing logged yet.Expected console output
{ email: 'asha@example.com', age: 21 }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
Console
Nothing logged yet.Expected console output
picked reactBreak 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) {.
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>.
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 understrictFunctionTypes(Topic 7.7) a handler written for a *narrower* event (sayMouseEvent<HTMLButtonElement>) can still be passed whereMouseEventHandler<HTMLElement>is expected. Method parameters are checked bivariantly, function-typed properties are not: a plain(e: MouseEvent<HTMLButtonElement>) => voidis 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 itstargetis stillEventTarget & 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 plainstring, so TypeScript cannot check that it names a real field: a misspeltname="emial"in the JSX compiles and adds a stray property at runtime. For forms with many fields prefer eitherFormDataparsed once on submit, or oneonChangeper field. - ▸
The browser's own
addEventListeneruses typed event maps (Topic 13.2); React'son*props are separate and typed throughDOMAttributes<T>. If you callelement.addEventListenerinside an effect you are back in the DOM types, not React's.
Remember this
- 1
The problem this solves: an event handler receives an object, and in plain JavaScript you guess what is on it, so
event.target.valueon 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/reactdescribe those wrappers. Each takes the element type as a type argument:MouseEvent<HTMLButtonElement>,KeyboardEvent<HTMLInputElement>,ChangeEvent<HTMLSelectElement>. - 2
Two targets, two meanings.
event.currentTargetis the element your handler is attached to, always exactly the element type in the type argument.event.targetis 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 aMouseEvent'stargetis only anEventTarget: 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 isChangeEvent, whosetargetReact types as the form element itself, because form fields can't contain other fields. - 3
Inline handlers need no annotation.
<input onChange={(event) => ...} />is contextually typed (Topic 2.4): TypeScript knowsonChangeexpects aChangeEventHandler<HTMLInputElement>, soeventis aChangeEvent<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
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 callpreventDefault, to read a modifier key), pass it as a second parameter, or type the prop with React's aliasChangeEventHandler<HTMLInputElement>, a ready-made function type for exactly this. - 5
Forms.
onSubmitreceives aSubmitEvent<HTMLFormElement>. ItstargetandcurrentTargetare the form, andsubmitteris the button that started the submit, ornullwhen there was none (for example when code calledform.requestSubmit()with no argument). You will also seeFormEvent<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; preferSubmitEventfor submit andChangeEventfor changes. Callevent.preventDefault()first, or the browser reloads the page. - 6
Typed form data.
new FormData(event.currentTarget)gives you an object of everything in the form, butform.get("email")has typeFormDataEntryValue | 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 ornull. Everything after that works withSignUpDataand never seesFormDataEntryValueagain. The same idea fixes numbers: an<input type="number">still gives text, so convert withNumber(...)and checkNumber.isNaN. - 7
Controlled inputs need two matching types. The state and the
valueprop:useState("")is a string,value={name}is a string. The event:event.target.valueis always astring, even for a number input; for a checkbox readevent.target.checked(aboolean) and for a number inputevent.target.valueAsNumber(anumber,NaNwhen empty). If your state is a number, you must convert; TypeScript will make you.
Explain it without notes
How do you type an onChange handler for an input, and where does the type of event.target.value come from?
What is the difference between event.target and event.currentTarget in React's types?
How do you type a callback prop like onChange on your own component, and why prefer the value over the event?
Why is FormData.get() not typed as string, and how do you deal with it?
Practice
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.
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 losespreventDefaultand modifier keys. Offer the event as a second argument for the few components that need it. - ↔
Parsing
FormDataby 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
targetandcurrentTargetare and narrowtarget.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
FormDatainto a typed object.