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:
| Event | Type |
|---|---|
| onClick | React.MouseEvent<HTMLButtonElement> |
| onChange (input) | React.ChangeEvent<HTMLInputElement> |
| onSubmit (form) | React.FormEvent<HTMLFormElement> |
| onKeyDown | React.KeyboardEvent<HTMLInputElement> |
| onFocus / onBlur | React.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.
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.
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.
| Item | Type | Meaning |
|---|---|---|
e.currentTarget | T (type argument) | the element the handler is attached to |
e.target | EventTarget | the 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.
<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.
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.
- In
setName(e.target.value),valueis alwaysstring.setNameaccepts onlystring, so it is safe. e: FormEvent<HTMLFormElement>inonSubmitautocompletese.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.
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.
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.
'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.
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 + zod —
register/handleSubmitcatch 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.
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:
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 #
- Write the controlled version of
SignupFormabove too. Bundle into one object withuseState<{ name: string; email: string; agreed: boolean }>and handle a sharedonChange. Compare which code is shorter and which is safer. - Meet the difference between
currentTargetandtargetdirectly. Attach a handler to a parent<div onClick={...}>with a<button>inside. Click the button and confirm in the console thate.targetis the button ande.currentTargetis the div. Also notice thattarget’s type is widelyEventTarget, so access like.valueis blocked. - Compare
FormDatawith the Chapter 27 Server Actions model. Pull the code inside theonSubmitofSignupFormabove into a separate functionsubmitSignup(formData: FormData), and haveonSubmitcall it aftere.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.valueis safe. Leave inline handlers to inference; pull them out and write the type explicitly. Single-field controlled forms use oneuseState; multi-field forms use an object +name. Uncontrolled forms read cleanest withFormDataand carry straight into the Chapter 27 Server Actions model. The result offormData.get()isFormDataEntryValue | null, so narrow once withtypeof === '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.