Skip to content

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:

Terminal window
npm create vite@latest my-app -- --template react-ts

React’s types live in separate packages, already included by that template:

Terminal window
npm install --save-dev @types/react @types/react-dom

Files 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.

Declare an interface (or type alias) and annotate the parameter. That is the whole pattern.

Button.tsx
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.

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 }>) { … }

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.

To build a wrapper that accepts everything the underlying element does, extend its prop type instead of listing attributes by hand.

TextField.tsx
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.

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.

Inference covers most cases:

const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [open, setOpen] = useState(false); // boolean

Provide 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 null
const [items, setItems] = useState<Todo[]>([]); // otherwise never[]
const [status, setStatus] = useState<'idle' | 'loading' | 'done'>('idle'); // otherwise string

Those 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 here

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 number

Getting the element type right matters for autocomplete: HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement, HTMLButtonElement, HTMLFormElement, HTMLDivElement, HTMLCanvasElement. If you only need a generic node, HTMLElement works.

Type the state and a discriminated union of actions, then annotate the reducer. TypeScript infers state and dispatch from it.

cartReducer.ts
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' }); // ok
dispatch({ type: 'removed' }); // Error: 'id' is missing
dispatch({ type: 'remove', id: '42' }); // Error: not a valid type

The 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;
}

Type the context value and use null as the default, then wrap access in a hook that narrows it.

AuthContext.tsx
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.

Two equivalent styles: type the event parameter, or type the whole handler.

// Type the parameter
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value); // string
}
// Type the handler; the parameter is then inferred
const 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
LoginForm.tsx
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>
);
}

When a component works with any item type, make it generic. The type flows from the props to the callbacks.

List.tsx
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>;
}

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.

useToggle.ts
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:

useFetch.ts
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');
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
  • Props are a parameter type: declare an interface, annotate the function, use ? plus default parameters for optionals.
  • children: React.ReactNode; skip React.FC.
  • Extend ComponentPropsWithoutRef<'element'> to build wrappers that accept all native attributes.
  • useState infers well; annotate explicitly for null unions, empty arrays, and literal unions.
  • useRef<HTMLInputElement>(null) for DOM nodes (current may be null); useRef(value) for mutable data.
  • Discriminated union actions make useReducer fully type-safe, including exhaustiveness checks.
  • Type context as T | null and 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 — prefer function declarations.
  • Return as const from hooks that return tuples, and validate fetched JSON at runtime rather than trusting an assertion.