TypeScript Best Practices for React Developers

I used to write all my React code in plain JavaScript. Then I tried TypeScript and... wow, I can't go back. Yeah, there's a learning curve, but catching bugs before they happen is worth it. Let me share what I've learned.
Why I Use TypeScript with React
Here's what sold me on TypeScript:
- Catch bugs early: Find errors while you're coding, not when users are clicking around
- Amazing autocomplete: Your editor knows what props a component needs
- Living documentation: The types tell you what each function expects
- Safe refactoring: Change something and TypeScript tells you what broke
- Team friendly: Everyone knows exactly what data they're working with
How to Type Your Components
Basic Components
Here's how I type most of my components:
// Good: Using React.FC is optional in modern TypeScript
interface ButtonProps {
label: string
onClick: () => void
variant?: 'primary' | 'secondary'
disabled?: boolean
}
export default function Button({
label,
onClick,
variant = 'primary',
disabled = false
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
)
}
Components with Children
When your component wraps other content:
interface CardProps {
title: string
children: React.ReactNode // For any valid React children
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
)
}
// For render props
interface RenderPropProps<T> {
data: T[]
render: (item: T) => React.ReactNode
}
function List<T>({ data, render }: RenderPropProps<T>) {
return (
<ul>
{data.map((item, index) => (
<li key={index}>{render(item)}</li>
))}
</ul>
)
}
Typing Events
Different elements need different event types:
// Form events
function LoginForm() {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Handle form submission
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target
// Handle input change
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
onChange={handleChange}
/>
</form>
)
}
// Click events
function Button() {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked', event.currentTarget)
}
return <button onClick={handleClick}>Click me</button>
}
// Keyboard events
function SearchInput() {
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
// Handle enter key
}
}
return <input onKeyDown={handleKeyDown} />
}
Typing React Hooks
useState
// Simple types
const [count, setCount] = useState<number>(0)
const [name, setName] = useState<string>('')
// Complex types
interface User {
id: number
name: string
email: string
}
const [user, setUser] = useState<User | null>(null)
// Array types
const [items, setItems] = useState<string[]>([])
// Object types
interface FormData {
username: string
email: string
age: number
}
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
age: 0,
})
useRef
// DOM element refs
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return <input ref={inputRef} />
}
// Mutable values
function Timer() {
const intervalRef = useRef<number | null>(null)
useEffect(() => {
intervalRef.current = window.setInterval(() => {
console.log('Tick')
}, 1000)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])
return <div>Timer Component</div>
}
useReducer
// Define state and action types
interface State {
count: number
error: string | null
}
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' }
// Reducer function
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
case 'DECREMENT':
return { ...state, count: state.count - 1 }
case 'SET_ERROR':
return { ...state, error: action.payload }
case 'RESET':
return { count: 0, error: null }
default:
return state
}
}
// Usage
function Counter() {
const [state, dispatch] = useReducer(reducer, {
count: 0,
error: null,
})
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
)
}
Custom Hooks
// Making your own hook
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(error)
return initialValue
}
})
const setValue = (value: T) => {
try {
setStoredValue(value)
window.localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error(error)
}
}
return [storedValue, setValue]
}
// Usage
function App() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
return <div>Current theme: {theme}</div>
}
Fetching Data from APIs
Typing Your API Responses
// Define API response types
interface ApiResponse<T> {
data: T
status: number
message: string
}
interface User {
id: number
name: string
email: string
}
// Generic fetch function
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
}
// Usage
async function getUser(id: number): Promise<User> {
const response = await fetchData<User>(`/api/users/${id}`)
return response.data
}
// With React Query
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery<User, Error>({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>Welcome, {data?.name}</div>
}
Useful TypeScript Helpers
TypeScript has some built-in helpers that are super useful:
// Partial - Make all properties optional
interface User {
id: number
name: string
email: string
}
function updateUser(id: number, updates: Partial<User>) {
// All User properties are now optional
}
// Required - Make all properties required
interface Config {
apiUrl?: string
timeout?: number
}
const requiredConfig: Required<Config> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
}
// Pick - Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Omit - Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>
// Record - Create object type with specific keys
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>
const roles: UserRoles = {
'user1': 'admin',
'user2': 'user',
}
// Readonly - Make all properties readonly
type ImmutableUser = Readonly<User>
Making Reusable Components
You can create components that work with any type of data:
interface DataTableProps<T> {
data: T[]
columns: {
key: keyof T
header: string
render?: (value: T[keyof T]) => React.ReactNode
}[]
}
function DataTable<T>({ data, columns }: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key])
: String(row[col.key])
}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// Usage
interface Product {
id: number
name: string
price: number
}
function ProductList() {
const products: Product[] = [
{ id: 1, name: 'Product 1', price: 99.99 },
]
return (
<DataTable
data={products}
columns={[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'Name' },
{
key: 'price',
header: 'Price',
render: (value) => `$${value}`
},
]}
/>
)
}
Mistakes I Made (Learn From Me)
1. Don't Use "any" Everywhere
// Don't do this
function processData(data: any) {
return data.map((item: any) => item.value)
}
// Do this instead
interface DataItem {
value: string
}
function processData(data: DataItem[]) {
return data.map(item => item.value)
}
2. Use Optional Chaining
// When data might not exist
interface User {
profile?: {
avatar?: string
}
}
function UserAvatar({ user }: { user: User }) {
// The ? checks if it exists before accessing it
const avatarUrl = user.profile?.avatar ?? '/default-avatar.png'
return <img src={avatarUrl} alt="User avatar" />
}
3. Be Careful with Type Assertions
// Try to avoid forcing types
const value = getValue() as string // Only use when you're 100% sure
// Better approach: check the type first
function isString(value: unknown): value is string {
return typeof value === 'string'
}
const value = getValue()
if (isString(value)) {
// Now TypeScript knows it's a string
console.log(value.toUpperCase())
}
Quick Tips
- Turn on strict mode: Add
"strict": trueto your tsconfig.json - catches way more bugs - Avoid "any": If you don't know the type, use
unknowninstead - Let TypeScript guess: You don't always need to write types, it can figure them out
- Use interfaces: For objects, interfaces are cleaner than types
- Make reusable components: Generics let you write components once, use everywhere
- Type your events: Be specific - MouseEvent, FormEvent, etc.
- Use utility types: Partial, Pick, Omit - they save so much time
- Check types at runtime: Use type guard functions when you're not sure
- Use readonly when you can: Prevents accidental changes
- Keep it simple: Don't over-complicate your types
Final Thoughts
TypeScript makes React development so much better. Yeah, you'll spend your first day wondering why you need to type everything, but then it'll save you hours of debugging later.
Main things to remember:
- Type your components and props properly
- Use the built-in helpers (Partial, Pick, etc.)
- Make reusable components with generics
- Don't abuse "any" or type assertions
- Let TypeScript figure stuff out when it can
The more you use TypeScript, the more tricks you'll learn. The community is really helpful too, so don't be afraid to ask questions!
Resources:
Rolando (Jun) Remolacio