Typing Component Props
The most common thing you'll type in React is component props:
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
}
// Usage
<Button label="Submit" onClick={()=> console.log("clicked")} />
<Button label="Delete" onClick={handleDelete} variant="danger" />
Children Props
There are several ways to type components that accept children:
// Using React.ReactNode (most flexible — accepts anything renderable)
interface CardProps {
title: string;
children: React.ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
// Using PropsWithChildren utility
import { PropsWithChildren } from "react";
type LayoutProps = PropsWithChildren<{
sidebar?: React.ReactNode;
}>;
function Layout({ children, sidebar }: LayoutProps) {
return (
<div className="layout">
{sidebar && <aside>{sidebar}</aside>}
<main>{children}</main>
</div>
);
}Typing Hooks
useState
// Type is inferred from the initial value
const [count, setCount] = useState(0); // number
const [name, setName] = useState("Sabaoon"); // string
// Explicit type when initial value doesn't tell the full story
const [user, setUser] = useState<User | null>(null);
interface User {
id: string;
name: string;
}
// Now TypeScript knows user could be null
if (user) {
console.log(user.name); // safe after null check
}
useRef
// DOM element ref — pass null as initial value
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus(); // optional chaining because current can be null
}
return <input ref={inputRef} />;
// Mutable value ref (not for DOM elements)
const timerRef = useRef<number>(0);
timerRef.current = window.setTimeout(() => {}, 1000);
useReducer
interface State {
count: number;
error: string | null;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; payload: number }
| { type: "error"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, error: null };
case "decrement":
return { ...state, count: state.count - 1, error: null };
case "reset":
return { ...state, count: action.payload, error: null };
case "error":
return { ...state, error: action.payload };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
dispatch({ type: "increment" });
dispatch({ type: "reset", payload: 0 });
// dispatch({ type: "reset" }); // Error: missing 'payload'
Typing Events
React has built-in types for every event:
function Form() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// process form
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// submit
}
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Submit</button>
</form>
);
}Tip: If you're not sure which event type to use, hover over the event handler prop in your editor — TypeScript will tell you the expected type.
Typing Context
import { createContext, useContext, useState, ReactNode } from "react";
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// Create context with a default value (or null + a custom hook)
const AuthContext = createContext<AuthContextType | null>(null);
// Custom hook with null check — no need to check for null at every call site
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Provider component
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const data = await res.json();
setUser(data.user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Usage in a component — fully typed, no null checks needed
function Profile() {
const { user, logout } = useAuth();
if (!user) return <p>Please log in.</p>;
return (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Log out</button>
</div>
);
}Generic Components
You can make components generic to work with different data types:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage — TypeScript infers T from the items array
interface Product {
id: string;
name: string;
price: number;
}
const products: Product[] = [
{ id: "1", name: "Keyboard", price: 79 },
{ id: "2", name: "Mouse", price: 49 },
];
<List
items={products}
keyExtractor={(p)=> p.id}
renderItem={(p)=> <span>{p.name} — ${p.price}</span>}
/>;