React with TypeScript
Type-safe React development — component props, hooks, events, context, and generic patterns with TypeScript.
React with TypeScript
TypeScript adds static type checking to your React code, catching errors at compile time instead of runtime. This guide covers the essential patterns for typing components, hooks, events, and state in modern React + TypeScript projects.
📦 Setup
Most React frameworks include TypeScript out of the box:
npx create-next-app@latest --typescript my-app
npm create vite@latest my-app -- --template react-ts
1. Typing Component Props
Define an interface or type for your props. Use interface when extending is likely; type for unions or intersections.
// Using an interface
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "danger"; // optional with union
disabled?: boolean;
onClick: () => void;
}
function Button({ label, variant = "primary", disabled, onClick }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}
2. Children Props
Use React.ReactNode for children that can be anything renderable. Use React.ReactElement if you need a specific JSX element.
interface CardProps {
title: string;
children: React.ReactNode; // accepts anything renderable
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
// PropsWithChildren shortcut
import { PropsWithChildren } from "react";
type LayoutProps = PropsWithChildren<{
sidebar: React.ReactNode;
}>;
function Layout({ sidebar, children }: LayoutProps) {
return (
<div className="flex">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
3. Typing useState
TypeScript infers useState types from the initial value. Explicitly type when the initial value doesn't represent all possible states.
// Inferred as string
const [name, setName] = useState(""); // string
// Explicit type needed — initial null, later an object
interface User {
id: number;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
// Later:
setUser({ id: 1, name: "Alice", email: "alice@example.com" });
// Access with narrowing
if (user) {
console.log(user.name); // TypeScript knows user is User here
}
4. Typing Events
React provides typed event interfaces. The most common ones you'll use:
function EventExamples() {
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
};
// Change events
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// Click events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("Clicked at:", e.clientX, e.clientY);
};
// Keyboard events
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("Enter pressed!");
}
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Submit</button>
</form>
);
}
5. Typing useRef
The type parameter determines whether the ref is mutable or read-only.
import { useRef, useEffect } from "react";
function RefExamples() {
// DOM ref — pass null as initial, React manages .current
const inputRef = useRef<HTMLInputElement>(null);
// Mutable ref — for storing values (not DOM)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
inputRef.current?.focus(); // Optional chaining — may be null
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
return <input ref={inputRef} />;
}
6. Typing Context
Create type-safe context with proper null handling to avoid runtime errors.
import { createContext, useContext, useState, ReactNode } from "react";
// 1. Define the context value type
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
// 2. Create context with undefined default
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 3. Custom hook with type guard
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// 4. Provider component
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const login = async (email: string, password: string) => {
setIsLoading(true);
const user = await authApi.login(email, password);
setUser(user);
setIsLoading(false);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
// 5. Usage — fully typed!
function Profile() {
const { user, logout } = useAuth();
if (!user) return <p>Not logged in</p>;
return (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
7. Typing useReducer
Use discriminated unions for actions to get exhaustive type checking in your reducer.
interface TodoState {
todos: Todo[];
filter: "all" | "active" | "completed";
}
// Discriminated union — each action has a unique 'type'
type TodoAction =
| { type: "ADD"; payload: string }
| { type: "TOGGLE"; payload: number }
| { type: "DELETE"; payload: number }
| { type: "SET_FILTER"; payload: TodoState["filter"] };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD":
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.payload, done: false }],
};
case "TOGGLE":
return {
...state,
todos: state.todos.map((t) =>
t.id === action.payload ? { ...t, done: !t.done } : t
),
};
case "DELETE":
return {
...state,
todos: state.todos.filter((t) => t.id !== action.payload),
};
case "SET_FILTER":
return { ...state, filter: action.payload };
// TypeScript ensures all cases are handled!
}
}
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: "all",
});
dispatch({ type: "ADD", payload: "Learn TypeScript" }); // ✅
dispatch({ type: "ADD", payload: 42 }); // ❌ Type error!
8. Generic Components
Build reusable, type-safe components that work with any data type using generics.
// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
if (items.length === 0) {
return <p className="text-gray-500">{emptyMessage ?? "No items"}</p>;
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// Usage — TypeScript infers T as User
interface User {
id: number;
name: string;
}
<List<User>
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => <span>{user.name}</span>}
/>
9. Typing Custom Hooks
Return types are usually inferred, but explicit typing makes the API contract clear.
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json: T = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"));
} finally {
setIsLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, error, isLoading, refetch: fetchData };
}
// Usage — type-safe!
interface Post {
id: number;
title: string;
body: string;
}
function Posts() {
const { data: posts, error, isLoading } = useFetch<Post[]>("/api/posts");
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return posts?.map((post) => <h3 key={post.id}>{post.title}</h3>);
}
10. Utility Types for React
Use built-in utility types to extend or modify component props.
// Extend native HTML element props
type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
};
function Input({ label, error, ...inputProps }: InputProps) {
return (
<div>
<label>{label}</label>
<input {...inputProps} />
{error && <span className="text-red-500">{error}</span>}
</div>
);
}
// ComponentProps — extract props from any component
type ButtonVariant = React.ComponentProps<typeof Button>["variant"];
// Omit — remove props you want to override
type CustomSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> & {
onChange: (value: string) => void; // Simplified onChange
};
// Pick — select specific props
type UserSummary = Pick<User, "id" | "name">;
⚠️ Common Pitfalls
- • Don't use
React.FC— it implicitly includes children and has issues with generics. Type props directly instead. - • Don't use
anyfor event handlers — use the specific React event types (React.ChangeEvent,React.MouseEvent, etc.). - • Don't forget to narrow nullable types —
useState<User | null>requires null checks before accessing properties. - • Avoid type assertions (
as) — prefer type guards and proper typing overas SomeType.
📋 React TypeScript Cheat Sheet
| Pattern | Type |
|---|---|
| Children | React.ReactNode |
| Style prop | React.CSSProperties |
| Form event | React.FormEvent<HTMLFormElement> |
| Change event | React.ChangeEvent<HTMLInputElement> |
| Click event | React.MouseEvent<HTMLButtonElement> |
| DOM ref | useRef<HTMLDivElement>(null) |
| Nullable state | useState<Type | null>(null) |
| Extend HTML props | React.HTMLAttributes<HTMLElement> |