TypeScript8 min read
TypeScript for React Developers: The Practical Guide
TypeScript for React developers: how to type props, children, events, refs, hooks and generic components, plus utility types and discriminated unions.
TypeScript for React mostly comes down to five things: typing props (including children), typing events, typing hooks and refs, writing generic components, and modelling variants with discriminated unions. Type the props parameter directly, use React.ReactNode for children, borrow native element props with React.ComponentProps<"button">, and let inference do the rest. The examples below target React 19 with current @types/react, and they compile with TypeScript 7.0, the new native compiler, the same way they do with 5.x.
Key takeaways
- Type props as a plain object type on the function parameter.
React.FCis optional and cannot express generic components. React.ReactNodeis the right default forchildren.- Extend native elements with
React.ComponentProps<"button">andOmitto avoid retyping every attribute. - In React 19,
refis a normal prop, soforwardRefis no longer needed. - Generic components (
<T,>) keep lists, tables and selects type-safe withoutany. - Discriminated unions make impossible prop combinations and impossible states fail at compile time.
Typing React props and children
import type { ReactNode } from "react";
type CardProps = {
title: string;
subtitle?: string; // optional
variant?: "default" | "outlined"; // string literal union
children: ReactNode;
};
export function Card({ title, subtitle, variant = "default", children }: CardProps) {
return (
<section className={`card card--${variant}`}>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
{children}
</section>
);
}That is the whole pattern for most components. A few notes:
typeorinterface? Both work for props. I default totypebecause props often need unions, which interfaces cannot express.- Default values go in the destructuring, and TypeScript narrows
variantto non-undefined inside the function. React.FCused to add an implicitchildrenprop, which caused bugs. That was removed in React 18's types, so it is harmless now, but typing the parameter directly is simpler and works for generics.
Which type for children?
| Type | Accepts | Use when |
|---|---|---|
ReactNode |
Elements, strings, numbers, arrays, null, undefined, booleans |
Default for almost every component |
ReactElement |
A single JSX element | You call cloneElement or need element props |
(value: T) => ReactNode |
A function | Render props |
PropsWithChildren<P> |
Adds children?: ReactNode to P |
Shorthand when children are optional |
Avoid JSX.Element for children: it rejects strings and null, which are perfectly valid children.
Extending native HTML element props
Do not retype onClick, disabled, type and 200 other attributes. Borrow them.
import type { ComponentProps } from "react";
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "ghost";
loading?: boolean;
};
export function Button({ variant = "primary", loading = false, children, disabled, ...rest }: ButtonProps) {
return (
<button {...rest} disabled={disabled || loading} data-variant={variant}>
{loading ? "Please wait..." : children}
</button>
);
}
// Override a native prop's type with Omit
type TextInputProps = Omit<ComponentProps<"input">, "onChange" | "size"> & {
onChange: (value: string) => void;
size?: "sm" | "md" | "lg";
};
export function TextInput({ onChange, size = "md", ...rest }: TextInputProps) {
return <input {...rest} data-size={size} onChange={(e) => onChange(e.target.value)} />;
}ComponentProps<"input"> includes ref in React 19, so TextInput accepts a ref with no extra work, since ref arrives in rest and gets spread onto the input. To get the props of another component, use ComponentProps<typeof Button>.
Typing events in React TypeScript
Inline handlers infer their event type, so you only need explicit types when the handler is defined separately.
import { useState, type ChangeEvent, type KeyboardEvent, type MouseEvent, type SubmitEvent } from "react";
export function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState("");
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") setQuery("");
}
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
onSearch(query);
}
function handleClear(e: MouseEvent<HTMLButtonElement>) {
e.currentTarget.blur();
setQuery("");
}
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={handleChange} onKeyDown={handleKeyDown} />
<button type="button" onClick={handleClear}>Clear</button>
<button type="submit">Search</button>
</form>
);
}A recent change worth knowing: newer @types/react 19 releases mark React.FormEvent as deprecated (the DOM has no "form event") and add SubmitEvent for onSubmit and InputEvent for onInput. FormEvent still compiles, but your editor will strike it through. On older type versions, keep using FormEvent<HTMLFormElement> for submit handlers.
Quick reference:
| Handler | Event type |
|---|---|
onChange on input, select, textarea |
ChangeEvent<HTMLInputElement> (or the matching element) |
onClick |
MouseEvent<HTMLButtonElement> |
onKeyDown |
KeyboardEvent<HTMLInputElement> |
onSubmit |
SubmitEvent<HTMLFormElement> (FormEvent on older types) |
onFocus / onBlur |
FocusEvent<HTMLInputElement> |
You can also type the whole handler: const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => ....
Typing hooks: useState, useRef and useReducer
import { useEffect, useReducer, useRef, useState } from "react";
type User = { id: string; name: string };
export function Profile() {
// Inferred as boolean
const [open, setOpen] = useState(false);
// Explicit when the initial value does not show the full type
const [user, setUser] = useState<User | null>(null);
// DOM ref: pass null, get RefObject<HTMLInputElement | null>
const inputRef = useRef<HTMLInputElement>(null);
// Mutable value ref
const renders = useRef(0);
useEffect(() => {
renders.current += 1;
inputRef.current?.focus();
});
return (
<div>
<input ref={inputRef} />
<button onClick={() => setOpen((o) => !o)}>{open ? "Hide" : "Show"}</button>
{open && <p>{user ? user.name : "Guest"}</p>}
<button onClick={() => setUser({ id: "1", name: "Sita" })}>Log in</button>
</div>
);
}
type CounterAction = { type: "increment" } | { type: "add"; amount: number } | { type: "reset" };
function counterReducer(state: number, action: CounterAction): number {
switch (action.type) {
case "increment":
return state + 1;
case "add":
return state + action.amount;
case "reset":
return 0;
}
}
export function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return <button onClick={() => dispatch({ type: "add", amount: 5 })}>{count}</button>;
}In React 19's types, useRef always needs an argument, and refs are mutable by default. The old split between MutableRefObject and read-only RefObject is gone.
For custom hooks, return tuples as const so TypeScript keeps the positions typed, or return an object. More patterns in custom React hooks with examples.
Generic React components
Generic components keep the item type flowing from props to callbacks. This is where any usually creeps in.
import type { Key, ReactNode } from "react";
type ListProps<T> = {
items: T[];
getKey: (item: T) => Key;
renderItem: (item: T) => ReactNode;
emptyText?: string;
};
export function List<T>({ items, getKey, renderItem, emptyText = "Nothing here" }: ListProps<T>) {
if (items.length === 0) return <p>{emptyText}</p>;
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage: T is inferred as { id: number; name: string }
export function Team() {
const members = [
{ id: 1, name: "Asha" },
{ id: 2, name: "Bikash" },
];
return <List items={members} getKey={(m) => m.id} renderItem={(m) => <strong>{m.name}</strong>} />;
}If you write the component as an arrow function in a .tsx file, use <T,> so the parser does not read <T> as a JSX tag: const List = <T,>(props: ListProps<T>) => .... Add constraints when you need them, such as <T extends { id: string }>.
Utility types React developers actually use
| Utility | What it does | React example |
|---|---|---|
Partial<T> |
Makes all properties optional | Patch payloads, form drafts |
Required<T> |
Makes all properties required | Resolved config after defaults |
Pick<T, K> |
Keeps listed keys | Pick<User, "id" | "name"> for a list row |
Omit<T, K> |
Removes listed keys | Overriding native props (above) |
Record<K, V> |
Object with keys K and values V |
Record<Status, string> for label maps |
ReturnType<typeof fn> |
Return type of a function | Types from a store or hook |
Awaited<T> |
Unwraps a Promise | Awaited<ReturnType<typeof fetchUser>> |
ComponentProps<C> |
Props of an element or component | Wrapping third-party components |
Pair Record with satisfies to check an object without widening it:
type Status = "idle" | "loading" | "success" | "error";
const statusLabel = {
idle: "Waiting",
loading: "Loading...",
success: "Done",
error: "Something went wrong",
} satisfies Record<Status, string>;
// Forget a key, and TypeScript errors. Keys stay as literal types.Discriminated unions for props and state
A discriminated union is a union of object types that share a literal "tag" property. TypeScript uses the tag to narrow the type. It is the most useful TypeScript pattern in React.
Props that depend on each other
type LinkButtonProps =
| { as: "button"; onClick: () => void; href?: never }
| { as: "link"; href: string; onClick?: never };
export function Action(props: LinkButtonProps & { label: string }) {
if (props.as === "link") {
return <a href={props.href}>{props.label}</a>;
}
return <button onClick={props.onClick}>{props.label}</button>;
}
// <Action as="link" href="/about" label="About" /> OK
// <Action as="link" onClick={() => {}} label="x" /> Error: href is missingThe ?: never fields block the wrong combination outright.
State that cannot be inconsistent
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
export function UserView({ state }: { state: FetchState<{ name: string }> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <p>Loading...</p>;
case "success":
return <p>Hello, {state.data.name}</p>;
case "error":
return <p role="alert">{state.error}</p>;
default:
return assertNever(state);
}
}Compare that with { isLoading: boolean; error?: string; data?: T }, where "loading with an error and data" is a valid type. With the union, it is not. Add a new status later and assertNever turns every unhandled switch into a compile error. For server data in real apps, TanStack Query gives you the same narrowing out of the box (see the TanStack Query guide).
Common TypeScript mistakes in React
anyfor event handlers or API data. Useunknownfor untrusted data and validate it, for example with Zod (covered in React Hook Form + Zod).- Type assertions instead of narrowing.
data as Usersilences the compiler without checking anything. - Optional everything. Five optional props that only make sense together should be a discriminated union.
JSX.Elementfor children. It rejects strings andnull. UseReactNode.- Duplicating types the library already exports.
ComponentProps,z.infer, andReturnTypeusually give you the type for free. - Turning off
strict. Most of the value comes from strict null checks.
If you learn only one pattern from this post, make it discriminated unions for loading state: it turns a whole category of runtime bugs into red squiggles in your editor. For the full reference, the TypeScript handbook on narrowing is worth reading end to end.
FAQ
What type should I use for children in React?
Use React.ReactNode for children that can be anything React can render: elements, strings, numbers, arrays, null or undefined. Use a narrower type such as React.ReactElement or a render function type only when the component really needs it.
Should I use type or interface for React props?
Either works. Many teams use type for props because it handles unions and intersections, which you need for discriminated union props. Pick one convention and apply it consistently.
Do I still need forwardRef in React 19 with TypeScript?
No. In React 19, function components receive ref as a regular prop. Type it as part of your props, for example with React.ComponentProps<'input'>, which already includes ref.
How do I type an onChange event in React TypeScript?
Use React.ChangeEvent with the element type, for example (e: React.ChangeEvent
Is React.FC still recommended?
It is no longer harmful since it stopped adding implicit children, but most codebases type the props parameter directly instead. That reads more simply and works the same way for generic components, which React.FC cannot express.
Wrapping up
You do not need advanced type gymnastics to get most of TypeScript's value in React. Type props directly, reuse native element props, let inference handle hooks and inline events, and reach for generics and discriminated unions when components get more flexible. Those habits catch the bugs that matter before they reach users.
- #typescript
- #react
- #types
- #generics
Keep reading
React7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read articleReact7 min read
React Hook Form + Zod: Type-Safe Form Validation Guide
Learn React Hook Form with Zod 4 for type-safe form validation: zodResolver setup, inferred types, cross-field rules, field arrays and server errors.
Read article