React with TypeScript
Type React components, hooks, events, context, and generic components for production-quality React TypeScript applications.
Typing Functional Components
The cleanest way to type a React component is to define a props interface and annotate the parameter directly. This approach is explicit, readable, and doesn’t pull in any React-specific wrapper type — the component is just a function that takes a typed object and returns JSX. Optional props get ?, and TypeScript will enforce at every call site that required props are provided.
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
loading?: boolean;
}
// TypeScript checks every usage — missing `label` or `onClick` is a compile error
function Button({ label, onClick, variant = "primary", disabled, loading }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled || loading}
>
{loading ? "Loading..." : label}
</button>
);
}
Children Props
React content passed between component tags arrives as the children prop. Using React.ReactNode is the right type for children because it accepts anything React can render — strings, elements, arrays, fragments, and null. Being explicit about whether a component accepts children, and what kind, prevents components from silently ignoring content passed to them.
import { ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode; // anything React can render
footer?: ReactNode; // optional — only some cards need a footer
}
function Card({ title, children, footer }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
For render props — where a component calls a function to produce its content — generics let the parent and child agree on the item type without any casting:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string;
}
// The generic <T> flows from the items array to the renderItem callback automatically
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
);
}
// Usage — TypeScript infers T as User from the items prop
<List
items={users}
keyExtractor={(u) => String(u.id)}
renderItem={(user) => <span>{user.name}</span>}
/>
Typing Hooks
React’s built-in hooks all have TypeScript generics that let you specify the types they work with. Getting these right means your state variables, dispatch functions, and refs all carry accurate types throughout the component, and TypeScript catches mistakes like setting the wrong type of value into state or dispatching an unknown action.
useState — TypeScript infers the type from the initial value, but for nullable or complex initial state you need to provide it explicitly:
import { useState } from "react";
// TypeScript infers these from the initial value
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
// For nullable initial state, provide the type parameter
const [user, setUser] = useState<User | null>(null);
// Discriminated union state — models all possible states of an async operation
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
const [state, setState] = useState<FetchState<User>>({ status: "idle" });
useReducer — the reducer function’s type constrains both valid actions and state transitions. Dispatching an unknown action type is a compile error:
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; payload: number };
interface State {
count: number;
history: number[];
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, history: [...state.history, state.count + 1] };
case "decrement":
return { ...state, count: state.count - 1, history: [...state.history, state.count - 1] };
case "reset":
return { count: action.payload, history: [] };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });
dispatch({ type: "increment" });
dispatch({ type: "reset", payload: 10 });
dispatch({ type: "multiply" }); // Error — not a valid action type
useRef — the type parameter specifies which DOM element the ref will attach to, giving you the correct set of DOM properties on ref.current:
import { useRef, useEffect } from "react";
function AutoFocusInput() {
// HTMLInputElement gives ref.current access to .focus(), .value, etc.
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // optional chaining — current is null until mounted
}, []);
return <input ref={inputRef} type="text" />;
}
// Mutable ref for values that should persist but not trigger re-renders
const countRef = useRef<number>(0);
countRef.current = 5; // mutable, won't trigger re-render
Custom hooks — type the return value explicitly so consumers get accurate types without having to look inside the hook:
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setData(await res.json());
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage — data is typed as User[] | null, not any
const { data: users, loading } = useFetch<User[]>("/api/users");
Typing Events
React’s synthetic event types are more specific than native DOM events and include useful properties for each element type. The generic parameter specifies the element that fired the event — ChangeEvent<HTMLInputElement> gives you e.target.value as a string, while ChangeEvent<HTMLSelectElement> gives you the selected option’s value. Getting these right eliminates the need for as casts inside event handlers.
import {
ChangeEvent,
FormEvent,
MouseEvent,
KeyboardEvent,
} from "react";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// ChangeEvent<HTMLInputElement> — e.target.value is string
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
// FormEvent<HTMLFormElement> — e.preventDefault() stops the page reload
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// handle login
};
// KeyboardEvent<HTMLInputElement> — e.key is string
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// submit
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
/>
<input
type="password"
value={password}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
Typed Context
React Context is the standard way to share state across a component tree without prop drilling. TypeScript makes context safer by ensuring the value you put in the Provider and the value you consume in child components have the same type. The custom hook pattern with a null check is the cleanest approach — it gives consumers a non-nullable type and catches the mistake of using the hook outside its Provider at runtime with a clear error message.
import { createContext, useContext, useState, ReactNode } from "react";
interface AuthUser {
id: string;
name: string;
email: string;
roles: string[];
}
interface AuthContextValue {
user: AuthUser | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
// null initial value — the custom hook enforces it's never null at the call site
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const login = async (email: string, password: string) => {
const res = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
const data: AuthUser = await res.json();
setUser(data);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: user !== null }}>
{children}
</AuthContext.Provider>
);
}
// Custom hook — throws a clear error if used outside AuthProvider
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx; // TypeScript knows ctx is AuthContextValue, not null
}
// Usage in a component — no null checks needed thanks to the custom hook
function ProfilePage() {
const { user, logout } = useAuth();
return (
<div>
<h1>Hello, {user?.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
}
Generic Components
Generic components let you build reusable UI that works with any data type while still being fully type-safe. A Select component that accepts T[] and a Select<User> that accepts User[] are the same component — the type parameter flows from the options prop through to the onChange callback, so TypeScript verifies that the type you put in is the type you get out.
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void; // called with the same type as options
getLabel: (option: T) => string; // extracts display text
getValue: (option: T) => string; // extracts unique key
placeholder?: string;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder,
}: SelectProps<T>) {
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
const selected = options.find((o) => getValue(o) === e.target.value);
if (selected !== undefined) onChange(selected);
};
return (
<select
value={value ? getValue(value) : ""}
onChange={handleChange}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((o) => (
<option key={getValue(o)} value={getValue(o)}>
{getLabel(o)}
</option>
))}
</select>
);
}
// Fully typed — TypeScript infers T as User from the options prop
<Select<User>
options={users}
value={selectedUser}
onChange={setSelectedUser} // (user: User) => void — no casting needed
getLabel={(u) => u.name}
getValue={(u) => String(u.id)}
placeholder="Select a user"
/>
Extending HTML Element Props
Custom components often wrap native HTML elements and need to accept all the same props the native element accepts — onClick, disabled, type, aria-* attributes, and so on. Extending the appropriate HTML attributes interface means you don’t have to manually declare every prop, and consumers can pass any valid native prop without TypeScript complaining.
import { ButtonHTMLAttributes, InputHTMLAttributes } from "react";
// Extends native button props — all standard button attributes are inherited
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: string; // custom prop
label: string; // custom prop
}
function IconButton({ icon, label, ...rest }: IconButtonProps) {
return (
// Spread the native props — onClick, disabled, type, etc. all pass through
<button {...rest} aria-label={label}>
<span className={`icon-${icon}`} />
{label}
</button>
);
}
// All standard button props work alongside the custom ones
<IconButton icon="trash" label="Delete" onClick={handleDelete} disabled={loading} />