Why TypeScript Matters in React
React components involve various data flows: props, state, events, context, and more. With JavaScript alone, the structure of props is unclear, making runtime errors and incorrect usage easy to introduce. Applying TypeScript explicitly defines component interfaces, providing IDE autocomplete, type checking, and refactoring safety all at once.
This article covers Props type definitions, event handling, custom Hooks, Context, and commonly used advanced patterns.
Props Type Definitions
Component props are defined using interface or type. Clearly specify optional properties, default values, and children types.
import { type ReactNode } from "react";
// Props interface definition
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "danger"; // Optional + union
size?: "sm" | "md" | "lg";
disabled?: boolean;
onClick: () => void; // Event handler
children?: ReactNode; // Child elements
}
// Default values specified in destructuring
function Button({
label,
variant = "primary",
size = "md",
disabled = false,
onClick,
children,
}: ButtonProps) {
// Size-based class mapping
const sizeClass: Record<string, string> = {
sm: "px-2 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`btn btn-${variant} ${sizeClass[size]}`}
disabled={disabled}
onClick={onClick}
>
{children ?? label}
</button>
);
}
// Type checking applied at usage
// <Button label="Save" onClick={() => console.log("saved")} />
// <Button label="Delete" variant="danger" size="lg" onClick={handleDelete} />
// <Button label="" variant="invalid" /> // Error! "invalid" is not in variant
// Generic Props: List component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string | number;
emptyMessage?: string;
}
function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = "No items",
}: ListProps<T>) {
if (items.length === 0) {
return <p className="text-gray-500">{emptyMessage}</p>;
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// Using the generic component
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
// <List
// items={users}
// renderItem={(user) => <span>{user.name}</span>}
// keyExtractor={(user) => user.id}
// />
Event Handling Types
React event objects use dedicated types like React.ChangeEvent, React.MouseEvent, etc.
import { type ChangeEvent, type FormEvent, useState } from "react";
interface LoginForm {
email: string;
password: string;
}
function LoginPage() {
const [form, setForm] = useState<LoginForm>({
email: "",
password: "",
});
const [errors, setErrors] = useState<Partial<LoginForm>>({});
// Input event: specify HTML element type in ChangeEvent
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
// Select event
const handleSelect = (e: ChangeEvent<HTMLSelectElement>) => {
console.log("Selected value:", e.target.value);
};
// Form submit event
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Simple validation
const newErrors: Partial<LoginForm> = {};
if (!form.email.includes("@")) {
newErrors.email = "Please enter a valid email";
}
if (form.password.length < 8) {
newErrors.password = "Password must be at least 8 characters";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
console.log("Login attempt:", form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Login</button>
</form>
);
}
Custom Hook Types
Explicitly typing custom Hook return values ensures accurate type inference at usage sites.
import { useState, useEffect, useCallback } from "react";
// Generic data fetching Hook
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | 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<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: T = await response.json();
setData(json);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage example
interface Post {
id: number;
title: string;
body: string;
}
function PostList() {
// T is inferred as Post[]
const { data: posts, loading, error, refetch } = useFetch<Post[]>(
"https://jsonplaceholder.typicode.com/posts"
);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
// Local storage Hook
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}
// Usage: type-safe local storage
// const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
// setTheme("dark"); // OK
// setTheme("blue"); // Error! "blue" is not allowed
Context Type Definitions
The common pattern for Context is to specify the type on createContext, then create a Provider and custom Hook together.
import { createContext, useContext, useReducer, type ReactNode } from "react";
// State type definition
interface AuthState {
user: { id: number; name: string; email: string } | null;
isAuthenticated: boolean;
loading: boolean;
}
// Action type definition (discriminated union)
type AuthAction =
| { type: "LOGIN_START" }
| { type: "LOGIN_SUCCESS"; payload: { id: number; name: string; email: string } }
| { type: "LOGIN_FAILURE"; payload: string }
| { type: "LOGOUT" };
// Context type definition
interface AuthContextType {
state: AuthState;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// Create Context (null initial value + custom Hook for safe access)
const AuthContext = createContext<AuthContextType | null>(null);
// Custom Hook: includes null check
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOGIN_START":
return { ...state, loading: true };
case "LOGIN_SUCCESS":
return { user: action.payload, isAuthenticated: true, loading: false };
case "LOGIN_FAILURE":
return { user: null, isAuthenticated: false, loading: false };
case "LOGOUT":
return { user: null, isAuthenticated: false, loading: false };
default:
return state;
}
}
// Provider component
function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthenticated: false,
loading: false,
});
const login = async (email: string, password: string) => {
dispatch({ type: "LOGIN_START" });
try {
// API call (example)
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const user = await response.json();
dispatch({ type: "LOGIN_SUCCESS", payload: user });
} catch {
dispatch({ type: "LOGIN_FAILURE", payload: "Login failed" });
}
};
const logout = () => dispatch({ type: "LOGOUT" });
return (
<AuthContext.Provider value={{ state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Consumer component
function UserProfile() {
const { state, logout } = useAuth();
// state.user is narrowed as null | User
if (!state.isAuthenticated || !state.user) {
return <p>Please log in</p>;
}
return (
<div>
<h2>{state.user.name}</h2>
<p>{state.user.email}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
Practical Tips
- Use
interfacefor Props: Declaration merging and extension make it suitable for component props. - Use React-specific event types: Types like
ChangeEvent<HTMLInputElement>andMouseEvent<HTMLButtonElement>provide accuratetargetproperty inference. - Explicitly type custom Hook returns: Defining complex return structures with interfaces makes usage sites clear.
- Context with null initial value + custom Hook: Creating with
createContext(null)and including null checks in the custom Hook prevents mistakes outside the Provider. - Generic components: Building generic components like
List<T>andTable<T>allows reuse across various data types. - Use
as const: When you need to preserve literal types,as constprevents type widening.