Essential TypeScript patterns and practices for building type-safe React applications.
TypeScript transforms how we write React applications. Here are the essential patterns you should know.
TypeScript adds static typing to JavaScript, catching errors at compile time, improving IDE support, and making refactoring safer. It's become the de facto standard for React development.
For component props, use interfaces rather than type aliases. Interfaces are extendable and better for object shapes:
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: "primary" | "secondary";
disabled?: boolean;
}
export function Button({ children, onClick, variant = "primary", disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled} className={`btn btn-${variant}`}>
{children}
</button>
);
}
Use React.ReactNode for children props as it accepts any renderable value (strings, numbers, elements, arrays, etc.).
Create reusable components with generics. This is powerful for list components and data tables:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyFn: (item: T) => string;
emptyMessage?: string;
}
export function List<T>({
items,
renderItem,
keyFn,
emptyMessage = "No items found"
}: ListProps<T>) {
if (items.length === 0) {
return <div className="text-muted-foreground">{emptyMessage}</div>;
}
return (
<ul className="space-y-2">
{items.map((item) => (
<li key={keyFn(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage with users
<List
items={users}
renderItem={(user) => <div>{user.name}</div>}
keyFn={(user) => user.id}
/>
// Usage with posts
<List
items={posts}
renderItem={(post) => <div>{post.title}</div>}
keyFn={(post) => post.slug}
/>
Type your custom hooks properly. Return an object for named exports:
interface UseAsyncResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useAsync<T>(
asyncFunction: () => Promise<T>,
deps: any[] = []
): UseAsyncResult<T> {
const [state, setState] = useState<UseAsyncResult<T>>({
data: null,
loading: true,
error: null,
refetch: async () => {}
});
const fetch = async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFunction();
setState({ data, loading: false, error: null, refetch: fetch });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : "Unknown error",
refetch: fetch
});
}
};
useEffect(() => {
fetch();
}, deps);
return state;
}
Always use proper event types from React. This provides autocomplete for event properties:
import type { ChangeEvent, FormEvent, MouseEvent } from "react";
export function Form() {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value); // TypeScript knows this is an HTMLInputElement
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Form submission logic
};
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY); // Mouse coordinates
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
<button onClick={handleClick}>Submit</button>
</form>
);
}
Don't use Event directly. Always use the specific event type like ChangeEvent, FormEvent, or MouseEvent with the element type.
Use discriminated unions for variant props. This is the most powerful TypeScript pattern for React:
type AlertVariant = "info" | "warning" | "error" | "success";
interface AlertProps {
variant: AlertVariant;
title?: string;
children: React.ReactNode;
onClose?: () => void;
}
const alertStyles: Record<AlertVariant, string> = {
info: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100",
warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100",
error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100",
success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100",
};
const alertIcons: Record<AlertVariant, React.ReactNode> = {
info: <Info className="h-4 w-4" />,
warning: <AlertTriangle className="h-4 w-4" />,
error: <XCircle className="h-4 w-4" />,
success: <CheckCircle className="h-4 w-4" />,
};
export function Alert({ variant, title, children, onClose }: AlertProps) {
return (
<div className={`p-4 rounded-lg ${alertStyles[variant]} relative`}>
<div className="flex items-start gap-2">
{alertIcons[variant]}
<div className="flex-1">
{title && <div className="font-semibold">{title}</div>}
<div className="text-sm">{children}</div>
</div>
{onClose && (
<button onClick={onClose} className="opacity-70 hover:opacity-100">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
Leverage TypeScript's built-in utility types. They save time and reduce verbosity:
// Make all props optional
type PartialUser = Partial<User>;
// Pick specific properties
type UserSummary = Pick<User, "id" | "name" | "email">;
// Omit specific properties (useful for create forms)
type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt">;
// Make specific properties readonly
type ReadonlyConfig = Readonly<Config>;
// Extract return type from a function
type UserResponse = ReturnType<typeof fetchUser>;
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Following these TypeScript patterns will help you write more maintainable, type-safe React applications. Your future self will thank you!