TypeScript Best Practices for React Developers
TypeScript has become the standard for building large-scale React applications. Here are the essential patterns and best practices every React developer should know.
Component Props Typing
Basic Props Interface
Always define interfaces for your component props:
interface ButtonProps {
children: React.ReactNode
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
onClick?: () => void
disabled?: boolean
}
const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
onClick,
disabled = false
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
)
}
Extending HTML Attributes
Extend native HTML attributes for better reusability:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string
error?: string
}
const Input: React.FC<InputProps> = ({ label, error, ...props }) => {
return (
<div>
<label>{label}</label>
<input {...props} />
{error && <span className="error">{error}</span>}
</div>
)
}
State Management
useState with TypeScript
Properly type your state:
// Simple state
const [count, setCount] = useState<number>(0)
// Complex state
interface User {
id: string
name: string
email: string
}
const [user, setUser] = useState<User | null>(null)
// Array state
const [items, setItems] = useState<string[]>([])
useReducer Pattern
For complex state logic:
interface State {
loading: boolean
data: User[]
error: string | null
}
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string }
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null }
case 'FETCH_SUCCESS':
return { ...state, loading: false, data: action.payload }
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload }
default:
return state
}
}
Custom Hooks
Typed Custom Hooks
Create reusable, well-typed hooks:
interface UseApiResult<T> {
data: T | null
loading: boolean
error: string | null
refetch: () => void
}
function useApi<T>(url: string): UseApiResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
setLoading(true)
const response = await fetch(url)
const result = await response.json()
setData(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
fetchData()
}, [fetchData])
return { data, loading, error, refetch: fetchData }
}
Event Handling
Properly Typed Event Handlers
Use specific event types:
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Handle form submission
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked')
}
Generic Components
Flexible, Reusable Components
Create components that work with different data types:
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
<List
items={users}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
API Integration
Typed API Responses
Define interfaces for your API responses:
interface ApiResponse<T> {
data: T
message: string
success: boolean
}
interface User {
id: string
name: string
email: string
avatar?: string
}
const fetchUsers = async (): Promise<ApiResponse<User[]>> => {
const response = await fetch('/api/users')
return response.json()
}
Utility Types
Leverage TypeScript's Built-in Utilities
Use utility types for better type safety:
// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Make all properties optional
type PartialUser = Partial<User>
// Make specific properties required
type RequiredUser = Required<Pick<User, 'name' | 'email'>>
// Exclude properties
type UserWithoutId = Omit<User, 'id'>
// Create union types
type Status = 'loading' | 'success' | 'error'
Error Boundaries
Typed Error Boundaries
Create type-safe 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>Something went wrong.</div>
}
return this.props.children
}
}
Best Practices Summary
- Always Type Props: Define interfaces for all component props
- Use Strict Mode: Enable strict TypeScript settings
- Avoid
any
: Use specific types orunknown
instead - Leverage Utility Types: Use built-in TypeScript utilities
- Type Event Handlers: Use specific event types
- Create Generic Components: Build reusable, flexible components
- Type API Responses: Define interfaces for external data
- Use Custom Hooks: Extract logic into typed custom hooks
Conclusion
TypeScript transforms React development by providing type safety, better IDE support, and improved maintainability. By following these patterns and best practices, you'll write more robust and scalable React applications.
Remember: good TypeScript is about finding the right balance between type safety and developer productivity.
Ready to level up your TypeScript skills? Check out my other articles on advanced React patterns and modern web development!