TypeScript Best Practices for React Applications
Discover advanced TypeScript patterns and best practices for building type-safe React applications.
TypeScript Best Practices for React Applications
TypeScript has become the standard for building scalable React applications. Let's explore advanced patterns and best practices that will level up your TypeScript game.
Type Safety from the Ground Up
1. Strict Mode is Your Friend
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
2. Component Props with Discriminated Unions
Use discriminated unions for components with different behaviors:
type ButtonProps =
| { variant: "primary"; onClick: () => void; children: React.ReactNode }
| { variant: "link"; href: string; children: React.ReactNode }
function Button(props: ButtonProps) {
if (props.variant === "primary") {
return
}
return {props.children}
}
// Usage - TypeScript will enforce the correct props
3. Generic Components
Create reusable components with generics:
interface ListProps {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
keyExtractor?: (item: T, index: number) => string | number
}
function List({ items, renderItem, keyExtractor }: ListProps) {
return (
{items.map((item, index) => (
-
{renderItem(item, index)}
))}
)
}
// Usage with full type safety
;
items={users}
renderItem={(user) => {user.name}}
keyExtractor={(user) => user.id}
/>
Advanced Patterns
Custom Hooks with Type Inference
function useLocalStorage(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(Error reading localStorage key "${key}":, error)
return initialValue
}
})
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(Error setting localStorage key "${key}":, error)
}
},
[key, storedValue]
)
return [storedValue, setValue]
}
// Usage with automatic type inference
const [user, setUser] = useLocalStorage('user', { name: '', email: '' })
// user is typed as { name: string, email: string }
Conditional Types for Props
type InputProps = {
value: T
onChange: (value: T) => void
type: T extends string ? 'text' | 'email' | 'password' : 'number'
}
function Input(props: InputProps) {
return (
type={props.type}
value={props.value}
onChange={(e) => {
const value =
props.type === 'number'
? (Number(e.target.value) as T)
: (e.target.value as T)
props.onChange(value)
}}
/>
)
}
Event Handlers with Proper Typing
interface FormData {
email: string
password: string
}
function LoginForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const handleInputChange = useCallback(
(event: React.ChangeEvent) => {
const { name, value } = event.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
},
[]
)
const handleSubmit = useCallback(
(event: React.FormEvent) => {
event.preventDefault()
// Form submission logic
},
[formData]
)
return (
)
}
Type Utilities and Helpers
Creating Utility Types
// Extract props from any component
type ComponentProps = T extends React.ComponentType ? P : never
// Make all properties optional
type PartialProps = {
[P in keyof T]?: T[P]
}
// Pick specific properties
type ApiUser = {
id: string
name: string
email: string
avatar: string
createdAt: string
}
type UserCard = Pick
type UserList = Pick
Extending HTML Elements
interface ButtonProps extends React.ButtonHTMLAttributes {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
const Button = React.forwardRef(
({ variant = 'primary', size = 'md', className, ...props }, ref) => {
return (
Error Boundaries with TypeScript
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 (
Something went wrong
{this.state.error?.message}
)
}
return this.props.children
}
}
Testing with TypeScript
import { fireEvent, render, screen } from '@testing-library/react'import { Button } from './Button'
describe('Button', () => {
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(
Click me
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('renders with correct variant styles', () => {
render()
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-red-500')
})
})
Performance Optimizations
Memoization with TypeScript
interface ExpensiveComponentProps {
data: ComplexData[]
onItemClick: (id: string) => void
}
const ExpensiveComponent = React.memo(
({ data, onItemClick }) => {
const processedData = useMemo(
() => data.map((item) => ({ ...item, processed: true })),
[data]
)
const handleClick = useCallback(
(id: string) => onItemClick(id),
[onItemClick]
)
return (
{processedData.map((item) => (
handleClick(item.id)}>
{item.name}
))}
)
}
)
Conclusion
TypeScript transforms React development by catching errors at compile time and providing excellent IDE support. By following these patterns and best practices, you'll build more maintainable, scalable, and reliable applications.
Key takeaways:
Remember: the goal is not just to satisfy the TypeScript compiler, but to write code that's easier to understand, maintain, and refactor.