TypeScript Best Practices for React Developers
Master TypeScript in React applications with advanced patterns, type safety techniques, and productivity tips that will make your code more robust and maintainable.

TypeScript Best Practices for React Developers
TypeScript has become an essential tool for modern React development, offering type safety, better developer experience, and improved code maintainability. In this comprehensive guide, we'll explore advanced TypeScript patterns and best practices specifically tailored for React developers.
Why TypeScript in React?
TypeScript brings numerous benefits to React development:
- 🛡️ Type Safety: Catch errors at compile time
- 📝 Better IntelliSense: Enhanced autocomplete and refactoring
- 🔄 Refactoring Confidence: Safe code changes across large codebases
- 📖 Self-Documenting Code: Types serve as inline documentation
- 🚀 Developer Productivity: Faster development with fewer runtime errors
Setting Up TypeScript with React
1. Project Initialization
# Create new Next.js project with TypeScript
npx create-next-app@latest my-app --typescript
# Or add TypeScript to existing React project
npm install --save-dev typescript @types/react @types/node
2. TypeScript Configuration
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["node_modules"]
}
Essential Type Patterns
1. Component Props Typing
// Basic Props Interface
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
onClick,
variant = 'primary',
disabled = false
}) => {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
2. Generic Components
// Generic List Component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: 'John', email: '[email protected]' }
];
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
/>
3. Event Handlers
// Form Event Handlers
interface FormProps {
onSubmit: (data: FormData) => void;
}
interface FormData {
username: string;
email: string;
}
const ContactForm: React.FC<FormProps> = ({ onSubmit }) => {
const [formData, setFormData] = useState<FormData>({
username: '',
email: ''
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(formData);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<form onSubmit={handleSubmit}>
<input
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="Username"
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
};
Advanced Patterns
1. Conditional Props with Discriminated Unions
// Button that's either a button or a link
type BaseButtonProps = {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
};
type ButtonAsButton = BaseButtonProps & {
as?: 'button';
onClick: () => void;
};
type ButtonAsLink = BaseButtonProps & {
as: 'link';
href: string;
external?: boolean;
};
type ButtonProps = ButtonAsButton | ButtonAsLink;
const Button: React.FC<ButtonProps> = (props) => {
if (props.as === 'link') {
return (
<a
href={props.href}
target={props.external ? '_blank' : undefined}
rel={props.external ? 'noopener noreferrer' : undefined}
className={`btn btn-${props.variant ?? 'primary'}`}
>
{props.children}
</a>
);
}
return (
<button
onClick={props.onClick}
className={`btn btn-${props.variant ?? 'primary'}`}
>
{props.children}
</button>
);
};
2. Custom Hooks with Generics
// Generic API Hook
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useApi<T>(url: string): ApiState<T> {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
fetchData();
}, [url]);
return state;
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const { data: user, loading, error } = useApi<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
3. Context with TypeScript
// Theme Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: React.ReactNode;
initialTheme?: 'light' | 'dark';
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
initialTheme = 'light'
}) => {
const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme);
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
const value = useMemo(() => ({
theme,
toggleTheme
}), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
Performance Optimization
1. Memo with TypeScript
// Memoized Component
interface ExpensiveComponentProps {
data: ComplexData[];
onItemClick: (id: string) => void;
}
const ExpensiveComponent = React.memo<ExpensiveComponentProps>(({
data,
onItemClick
}) => {
return (
<div>
{data.map(item => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
))}
</div>
);
});
ExpensiveComponent.displayName = 'ExpensiveComponent';
2. useCallback and useMemo
interface ProductListProps {
products: Product[];
searchTerm: string;
}
const ProductList: React.FC<ProductListProps> = ({ products, searchTerm }) => {
// Memoize expensive computation
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [products, searchTerm]);
// Memoize event handler
const handleProductClick = useCallback((productId: string) => {
console.log('Product clicked:', productId);
// Handle click logic
}, []);
return (
<div>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onClick={handleProductClick}
/>
))}
</div>
);
};
Error Handling
1. Error Boundaries
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
</div>
);
}
return this.props.children;
}
}
2. Result Type Pattern
// Result type for better error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
success: false,
error: new Error(`Failed to fetch user: ${response.status}`)
};
}
const user = await response.json();
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown error')
};
}
}
// Usage
const UserComponent: React.FC<{ userId: string }> = ({ userId }) => {
const [result, setResult] = useState<Result<User> | null>(null);
useEffect(() => {
fetchUser(userId).then(setResult);
}, [userId]);
if (!result) return <div>Loading...</div>;
if (!result.success) {
return <div>Error: {result.error.message}</div>;
}
return <div>Welcome, {result.data.name}!</div>;
};
Testing with TypeScript
1. Component Testing
// __tests__/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../Button';
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('applies correct variant class', () => {
render(<Button onClick={() => {}} variant="danger">Delete</Button>);
expect(screen.getByText('Delete')).toHaveClass('btn-danger');
});
});
2. Mock Typing
// Mock with proper typing
const mockUser: User = {
id: 1,
name: 'John Doe',
email: '[email protected]',
};
jest.mock('../api/userService', () => ({
fetchUser: jest.fn().mockResolvedValue(mockUser),
updateUser: jest.fn().mockResolvedValue(mockUser),
}));
Common Pitfalls and Solutions
1. Any Type Usage
// ❌ Avoid using 'any'
const processData = (data: any) => {
return data.someProperty; // No type safety
};
// ✅ Use proper typing
interface ApiResponse {
users: User[];
total: number;
}
const processData = (data: ApiResponse) => {
return data.users; // Type safe
};
// ✅ For truly unknown data, use 'unknown'
const processUnknownData = (data: unknown) => {
if (typeof data === 'object' && data !== null && 'users' in data) {
// Type guard ensures safety
return (data as ApiResponse).users;
}
throw new Error('Invalid data format');
};
2. Optional vs Required Props
// ✅ Clear distinction between optional and required
interface ComponentProps {
// Required props
id: string;
title: string;
// Optional props
description?: string;
onClose?: () => void;
// Props with defaults (mark as optional)
variant?: 'primary' | 'secondary';
}
const Component: React.FC<ComponentProps> = ({ id, title, description, onClose, variant = 'primary' }) => {
// Implementation
};
Conclusion
TypeScript significantly enhances React development by providing type safety, better tooling, and improved code maintainability. By following these patterns and best practices, you'll write more robust and scalable React applications.
Key takeaways:
- Start with strict TypeScript configuration
- Use proper component prop typing
- Leverage generics for reusable components
- Implement proper error handling
- Write comprehensive tests with TypeScript
Master these TypeScript patterns and elevate your React development to the next level!
Want to learn more? Check out the TypeScript Handbook and explore advanced type system features.