I16 · Part 2 · Intermediate
TypeScript with React
Props are a function's parameters, so typing a component is typing a function.
17On this page
- Start here
- The types you will use daily
- Extending a DOM element
- Refs are props now
- When your prop clashes with the element's
- Defaults make optional props required inside
- useState needs help only sometimes
- Refs
- What changed in React 19's types
- Discriminated unions make impossible states impossible
- Typing a reducer
- Generic components
- What children can and cannot promise
- Events
- Context without the null dance
- Data from outside is unknown
- Two habits worth forming
Start here
type ButtonProps = {
label: string;
onClick: () => void;
variant?: "primary" | "ghost"; // optional, and only these two
children?: React.ReactNode;
};
function Button({ label, onClick, variant = "primary" }: ButtonProps) { ... }
No React.FC. It adds nothing useful, makes generics awkward, and
historically implied a children prop the component may not
accept. Annotate the parameter — that is the whole pattern.
type or interface for props? Either works. Pick one
for the codebase; type is the more common choice because unions
and intersections, which props need constantly, only work with it.
The types you will use daily
| Type | For |
|---|---|
React.ReactNode | Anything renderable — the right type for children |
React.ReactElement | Specifically a JSX element, not a string or null |
React.ComponentProps<"button"> | Every prop a real <button> takes |
React.ChangeEvent<HTMLInputElement> | An input's change event |
React.FormEvent<HTMLFormElement> | A form submit |
React.MouseEvent<HTMLButtonElement>, React.KeyboardEvent | Clicks and key presses |
React.CSSProperties | A style object |
React.JSX.Element | What a component returns, when you annotate it |
Extending a DOM element
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "ghost";
};
function Button({ variant = "primary", ...rest }: ButtonProps) {
return <button className={"btn btn--" + variant} {...rest} />;
}
Now type, disabled, aria-label,
onClick and every other button attribute are typed correctly and
autocompleted, without listing one of them. This is the single most useful
React-specific TypeScript pattern.
Refs are props now
function TextInput(props: React.ComponentProps<"input">) {
return <input className="field" {...props} />; // ref arrives inside props
}
const ref = useRef<HTMLInputElement>(null);
<TextInput ref={ref} /> // ✓ no forwardRef
Since React 19 a function component receives ref as an ordinary
prop, and ComponentProps<"input"> already includes it. If
you need to be explicit, ComponentPropsWithRef and
ComponentPropsWithoutRef say which one you mean — the
second is right for a wrapper that must not pass a ref through.
When your prop clashes with the element's
type InputProps = Omit<React.ComponentProps<"input">, "size"> & {
size?: "sm" | "md" | "lg"; // the DOM's size is a number of characters
};
function Input({ size = "md", ...rest }: InputProps) {
return <input className={"input input--" + size} {...rest} />;
}
Intersecting two types that both define size does not replace
one with the other; it demands a value that is a number and a string at once,
which nothing is. Omit removes the element's version first.
size, color, type and
onChange are the usual collisions.
Defaults make optional props required inside
variant?: "primary" | "ghost" means the caller may leave it out.
Inside the component, after variant = "primary" in the parameter
list, TypeScript knows it is always defined, so there is no
undefined check to write. That is why defaults belong in the
destructuring rather than in an if further down: the type and the
behaviour are declared in the same place.
useState needs help only sometimes
const [count, setCount] = useState(0); // inferred: number
const [user, setUser] = useState<User | null>(null); // otherwise: always null
const [items, setItems] = useState<Todo[]>([]); // otherwise: never[]
Inference is right whenever the initial value is representative. It is wrong
for null and for an empty array, which are the two cases worth
remembering.
Refs
const input = useRef<HTMLInputElement>(null);
input.current?.focus(); // null until mounted
const timer = useRef<number | undefined>(undefined); // a mutable box
The optional chaining is not defensive noise — a DOM ref genuinely is
null during the first render, and the type says so. Since React
19, useRef requires an argument, and every ref object is mutable.
What changed in React 19's types
| Before | Now |
|---|---|
useRef() with no argument | Compile error — pass undefined or null |
ref={(el) => (node = el)} | Error: a ref callback may now return a cleanup function, so an implicit return is rejected. Use braces. |
Global JSX namespace | React.JSX |
ReactElement["props"] was any | Now unknown unless you pass a type argument |
useReducer<React.Reducer<S, A>>(reducer) | useReducer(reducer) — let it infer |
npx types-react-codemod@latest preset-19 ./src // fixes most of these mechanically
Discriminated unions make impossible states impossible
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "ready"; data: User };
if (state.status === "ready") {
state.data.name; // ✓ narrowed — data exists here
state.error; // ✗ compile error — and rightly so
}
This is the type-level version of the reducer argument, and it is where TypeScript earns its place in a React codebase: not catching typos, but making a whole class of UI bug unrepresentable.
Typing a reducer
type Action =
| { type: "added"; text: string }
| { type: "removed"; id: string }
| { type: "cleared" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "added": return { ...state, items: [...state.items, action.text] };
case "removed": return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "cleared": return { ...state, items: [] };
}
}
With no default case, adding a fourth action type makes the
function fail to return on one path — a compile error pointing exactly at
the reducer you forgot to update. That exhaustiveness check is free and
genuinely prevents bugs. Inside each case, action is
narrowed, so action.id only exists where the type has one.
Generic components
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyOf: (item: T) => string;
};
function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
return <ul>{items.map((i) => <li key={keyOf(i)}>{renderItem(i)}</li>)}</ul>;
}
<List items={users} keyOf={(u) => u.id} renderItem={(u) => u.name} />
// u is inferred as User — no annotation needed at the call site
What children can and cannot promise
children: React.ReactNode; // anything renderable
children: React.ReactElement; // exactly one element, not text
children: (state: { open: boolean }) => React.ReactNode; // a render function
One limit surprises people: you cannot type children as "only
<Tab> elements". Every JSX expression has the same type,
React.JSX.Element, whatever component produced it, so
<Tabs><div /></Tabs> compiles even when the prop
looks strict. If the structure matters, enforce it with an API instead
— an items array prop, or compound components that read
shared context.
Events
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value); // typed as string
}
<input onChange={(e) => setValue(e.target.value)} />
// inline: e is inferred from the JSX attribute — no annotation needed
Inline handlers are inferred from context. You only annotate when the function is defined separately, which is worth knowing before you decorate every arrow with a type.
For your own components, type callback props by what they mean, not by the
DOM event that triggered them: onSelect: (id: string) => void
rather than onSelect: (e: React.MouseEvent) => void. The parent
wants the id; making it dig through an event couples it to your markup.
Context without the null dance
const AuthContext = createContext<Auth | null>(null);
export function useAuth(): Auth {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx; // narrowed — callers get Auth
}
One runtime check in one place, and every consumer gets a non-null type. The alternative — optional chaining at forty call sites — is worse in every way.
Data from outside is unknown
const res = await fetch("/api/user");
const user = (await res.json()) as User; // ✗ a promise to the compiler, not a check
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await res.json()); // ✓ checked at runtime, typed after
TypeScript types vanish when the code runs, so an API that returns a
different shape passes an as cast silently and fails later, far
from the cause. Parsing at the boundary turns that into one clear error where
the data enters, and the schema doubles as the type.
Two habits worth forming
-
Avoid
any; preferunknown.anyswitches the checker off and the hole spreads through everything it touches.unknownforces you to narrow before use. -
Derive types rather than restating them.
type FormValues = z.infer<typeof schema>, orPick<User, "id" | "name">. A type written twice is a type that will disagree with itself.
Interview answer, one sentence
"Typing a component is typing a function's parameter, so I annotate props
and skip React.FC, and I extend
ComponentProps<'button'> rather than restating DOM
attributes — which since React 19 includes ref. The real
payoff is discriminated unions for state — narrowing means the compiler
stops you reading data in the error branch."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series