TypeScript with React
React and TypeScript fit together well because a component is just a function: props are its parameter type, JSX is its return type. Almost everything else is inference. This page assumes you know the TypeScript basics — interfaces, unions, and generics especially.
Scaffold with the TypeScript template:
npm create vite@latest my-app -- --template react-tsReact’s types live in separate packages, already included by that template:
npm install --save-dev @types/react @types/react-domFiles containing JSX must use the .tsx extension. In tsconfig.json, "jsx": "react-jsx" selects the automatic runtime, so you never import React just to use JSX.
Typing props
Section titled “Typing props”Declare an interface (or type alias) and annotate the parameter. That is the whole pattern.
interface ButtonProps { label: string; onClick: () => void; variant?: 'primary' | 'secondary'; // optional disabled?: boolean;}
export function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) { return ( <button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}> {label} </button> );}? makes a prop optional — its type becomes T | undefined, and a default parameter narrows it back. A union of string literals like 'primary' | 'secondary' gives autocomplete at the call site and rejects typos, which is far better than string.
interface and type are interchangeable here. interface supports declaration merging and extension; type supports unions and intersections. Pick one and be consistent.
Children
Section titled “Children”React.ReactNode is the type of “anything React can render”: elements, strings, numbers, arrays, null, undefined, booleans.
interface CardProps { title: string; children: React.ReactNode;}
function Card({ title, children }: CardProps) { return ( <section> <h2>{title}</h2> {children} </section> );}Use React.ReactNode for children and for any prop that accepts renderable content. The narrower React.ReactElement means specifically an element — useful when you must call cloneElement or read props, but it rejects plain strings, so it is rarely what you want.
PropsWithChildren<P> is a shorthand that adds children?: ReactNode to your props type:
function Card({ title, children }: React.PropsWithChildren<{ title: string }>) { … }Don’t reach for React.FC
Section titled “Don’t reach for React.FC”You will see this in older code:
const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>;A plain annotated function is simpler, plays better with generics, and does not force a specific return type. Since @types/react 18, React.FC no longer adds children implicitly, which removed its main historical reason to exist.
Extending DOM element props
Section titled “Extending DOM element props”To build a wrapper that accepts everything the underlying element does, extend its prop type instead of listing attributes by hand.
interface TextFieldProps extends React.ComponentPropsWithoutRef<'input'> { label: string;}
export function TextField({ label, id, ...rest }: TextFieldProps) { return ( <div> <label htmlFor={id}>{label}</label> <input id={id} {...rest} /> </div> );}Now placeholder, type, onFocus, aria-*, and everything else type-check for free. Use ComponentProps<'input'> when you also want to accept a ref, and ComponentProps<typeof SomeComponent> to borrow another component’s props.
Function props
Section titled “Function props”Type callbacks by their signature — this catches wrong argument counts and types at the call site.
interface Props { onSelect: (id: string) => void; onSubmit: (values: FormValues) => Promise<void>; renderItem: (item: Item, index: number) => React.ReactNode;}To pass a useState setter down as a prop, its type is React.Dispatch<React.SetStateAction<T>>:
interface Props { count: number; setCount: React.Dispatch<React.SetStateAction<number>>;}Prefer a narrow callback (onIncrement: () => void) when you can — handing a child the raw setter couples it to your state shape.
Typing hooks
Section titled “Typing hooks”useState
Section titled “useState”Inference covers most cases:
const [count, setCount] = useState(0); // numberconst [name, setName] = useState(''); // stringconst [open, setOpen] = useState(false); // booleanProvide an explicit type parameter when the initial value does not describe the full range of values:
const [user, setUser] = useState<User | null>(null); // otherwise inferred as nullconst [items, setItems] = useState<Todo[]>([]); // otherwise never[]const [status, setStatus] = useState<'idle' | 'loading' | 'done'>('idle'); // otherwise stringThose three are the whole list of situations you need to think about: null initial values, empty arrays, and string literals that should be a union.
After a null union, TypeScript’s narrowing works normally:
if (!user) return <Spinner />;return <h1>{user.name}</h1>; // user is User hereuseRef
Section titled “useRef”Two distinct shapes.
DOM refs — type the element and initialize with null:
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); // current may be null before mount}, []);The optional chaining is not optional in practice: current is HTMLInputElement | null because the node does not exist on the first render.
Mutable values — type the value and give a real initial value:
const renderCount = useRef(0); // RefObject<number>const timerRef = useRef<number | null>(null); // browser setTimeout returns a numberGetting the element type right matters for autocomplete: HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement, HTMLButtonElement, HTMLFormElement, HTMLDivElement, HTMLCanvasElement. If you only need a generic node, HTMLElement works.
useReducer
Section titled “useReducer”Type the state and a discriminated union of actions, then annotate the reducer. TypeScript infers state and dispatch from it.
interface CartItem { id: string; name: string; quantity: number;}
interface CartState { items: CartItem[]; discount: number;}
type CartAction = | { type: 'added'; item: CartItem } | { type: 'removed'; id: string } | { type: 'quantity_changed'; id: string; quantity: number } | { type: 'cleared' };
const initialState: CartState = { items: [], discount: 0 };
function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'added': return { ...state, items: [...state.items, action.item] }; case 'removed': return { ...state, items: state.items.filter((i) => i.id !== action.id) }; case 'quantity_changed': return { ...state, items: state.items.map((i) => i.id === action.id ? { ...i, quantity: action.quantity } : i ), }; case 'cleared': return initialState; }}const [state, dispatch] = useReducer(cartReducer, initialState);
dispatch({ type: 'removed', id: '42' }); // okdispatch({ type: 'removed' }); // Error: 'id' is missingdispatch({ type: 'remove', id: '42' }); // Error: not a valid typeThe type field is the discriminant: inside case 'added', TypeScript narrows action to that member, so action.item exists and action.id is an error. This is the single biggest win TypeScript gives React code.
Annotating the reducer’s return type as CartState makes it an error to forget a field. If you also want exhaustiveness checking, add a default branch:
default: { const _exhaustive: never = action; // errors if a new action type is unhandled return state;}useContext
Section titled “useContext”Type the context value and use null as the default, then wrap access in a hook that narrows it.
import { createContext, useContext, useMemo, useState } from 'react';
interface User { id: string; name: string;}
interface AuthValue { user: User | null; login: (email: string, password: string) => Promise<void>; logout: () => void;}
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null);
const value = useMemo<AuthValue>( () => ({ user, login: async (email, password) => { … }, logout: () => setUser(null), }), [user] );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;}
export function useAuth(): AuthValue { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>'); return ctx;}Consumers call useAuth() and get a non-nullable AuthValue — no optional chaining anywhere, and using the hook outside the provider fails loudly instead of silently reading a fake default.
Typing events and handlers
Section titled “Typing events and handlers”Two equivalent styles: type the event parameter, or type the whole handler.
// Type the parameterfunction handleChange(e: React.ChangeEvent<HTMLInputElement>) { setValue(e.target.value); // string}
// Type the handler; the parameter is then inferredconst handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { setValue(e.target.value);};The generic argument is the element the handler is attached to, which is what gives e.target and e.currentTarget useful types.
| Situation | Event type |
|---|---|
| Text input / textarea / select change | React.ChangeEvent<HTMLInputElement> (or HTMLTextAreaElement, HTMLSelectElement) |
| Form submit | React.FormEvent<HTMLFormElement> |
| Click | React.MouseEvent<HTMLButtonElement> |
| Keyboard | React.KeyboardEvent<HTMLInputElement> |
| Focus / blur | React.FocusEvent<HTMLInputElement> |
| Any React event | React.SyntheticEvent |
function LoginForm() { const [email, setEmail] = useState('');
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); login(email); }
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { if (e.key === 'Escape') setEmail(''); }
return ( <form onSubmit={handleSubmit}> <input value={email} onChange={(e) => setEmail(e.target.value)} onKeyDown={handleKeyDown} /> </form> );}Generic components
Section titled “Generic components”When a component works with any item type, make it generic. The type flows from the props to the callbacks.
interface ListProps<T> { items: T[]; getKey: (item: T) => string; renderItem: (item: T) => React.ReactNode;}
export function List<T>({ items, getKey, renderItem }: ListProps<T>) { return ( <ul> {items.map((item) => ( <li key={getKey(item)}>{renderItem(item)}</li> ))} </ul> );}<List items={users} // User[] getKey={(u) => u.id} // u is User, inferred renderItem={(u) => <strong>{u.name}</strong>}/>Constrain the parameter when the component requires certain fields:
export function KeyedList<T extends { id: string }>({ items }: { items: T[] }) { return <ul>{items.map((item) => <li key={item.id}>…</li>)}</ul>;}Typing custom hooks
Section titled “Typing custom hooks”Annotate the return value, or let inference do it. The one case that needs help is an array return, which TypeScript widens to a union array instead of a tuple.
import { useState, useCallback } from 'react';
export function useToggle(initial = false) { const [on, setOn] = useState(initial); const toggle = useCallback(() => setOn((v) => !v), []); return [on, toggle] as const; // readonly [boolean, () => void]}Without as const the return type is (boolean | (() => void))[], and destructuring gives both variables that useless union. as const (or an explicit tuple annotation) fixes it — this is exactly how useState’s own types work.
Return an object when there are more than two or three values:
interface FetchState<T> { data: T | null; error: Error | null; isLoading: boolean;}
export function useFetch<T>(url: string): FetchState<T> { const [state, setState] = useState<FetchState<T>>({ data: null, error: null, isLoading: true, });
useEffect(() => { let ignore = false; const controller = new AbortController();
fetch(url, { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }) .then((data) => { if (!ignore) setState({ data, error: null, isLoading: false }); }) .catch((error: unknown) => { if (ignore || (error instanceof Error && error.name === 'AbortError')) return; setState({ data: null, error: error as Error, isLoading: false }); });
return () => { ignore = true; controller.abort(); }; }, [url]);
return state;}const { data, error, isLoading } = useFetch<User[]>('/api/users');Useful types at a glance
Section titled “Useful types at a glance”| Type | Use |
|---|---|
React.ReactNode |
Anything renderable — the default for children |
React.ReactElement |
Specifically a JSX element |
React.CSSProperties |
An inline style object |
React.ComponentProps<'button'> |
All props of a DOM element |
React.ComponentPropsWithoutRef<'input'> |
The same, minus ref — for wrapper components |
React.PropsWithChildren<P> |
P plus optional children |
React.Dispatch<React.SetStateAction<T>> |
A useState setter passed as a prop |
React.Ref<T> / React.RefObject<T> |
A ref accepted as a prop / returned by useRef |
React.ChangeEvent<T>, React.FormEvent<T>, React.MouseEvent<T> |
Event objects |
Key points
Section titled “Key points”- Props are a parameter type: declare an interface, annotate the function, use
?plus default parameters for optionals. children: React.ReactNode; skipReact.FC.- Extend
ComponentPropsWithoutRef<'element'>to build wrappers that accept all native attributes. useStateinfers well; annotate explicitly fornullunions, empty arrays, and literal unions.useRef<HTMLInputElement>(null)for DOM nodes (currentmay benull);useRef(value)for mutable data.- Discriminated union actions make
useReducerfully type-safe, including exhaustiveness checks. - Type context as
T | nulland expose a guard hook so consumers get a non-nullable value. - Inline handlers infer their event type; extracted ones need
React.ChangeEvent<HTMLInputElement>and friends. - Generic components need
<T,>in arrow form — preferfunctiondeclarations. - Return
as constfrom hooks that return tuples, and validate fetched JSON at runtime rather than trusting an assertion.