Master TypeScript in your React applications with these essential best practices. Learn proper typing, advanced patterns, and common pitfalls to avoid. Make your code more maintainable.

Yusuf Falade

TypeScript has become the standard for modern React development, and for good reason. It catches bugs at compile time, provides excellent IDE support, and makes your code more maintainable and self-documenting.
However, using TypeScript effectively requires understanding its features and following best practices. In this guide, we'll explore the essential patterns and techniques that will make you a more effective React + TypeScript developer.
Before diving into best practices, let's understand why TypeScript is crucial for React development:
Catch Errors Early TypeScript catches type errors before your code runs, preventing runtime crashes and reducing bugs in production.
Better IDE Support Get autocomplete, intelligent suggestions, and instant error feedback as you type.
Self-Documenting Code Types serve as inline documentation, making it easier for others (and future you) to understand your code.
Safer Refactoring Confidently refactor your code knowing TypeScript will catch any breaking changes.
Always define explicit interfaces for your component props. This makes your components self-documenting and catches errors early.
❌ Bad:
function Button(props: any) {
return <button>{props.text}</button>
}✅ Good:
interface ButtonProps {
text: string
onClick: () => void
disabled?: boolean
variant?: 'primary' | 'secondary' | 'danger'
}
function Button({ text, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn-${variant}`}
>
{text}
</button>
)
}Key Points:
interface for prop types?The any type defeats the purpose of TypeScript. Use unknown when you truly don't know the type.
❌ Bad:
function processData(data: any) {
return data.value.toUpperCase()
}✅ Good:
function processData(data: unknown): string {
if (
typeof data === 'object' &&
data !== null &&
'value' in data &&
typeof data.value === 'string'
) {
return data.value.toUpperCase()
}
throw new Error('Invalid data format')
}Type guards help TypeScript narrow down types, making your code safer and more readable.
Create Custom Type Guards:
interface User {
id: string
name: string
email: string
}
interface Admin extends User {
role: 'admin'
permissions: string[]
}
// Type guard function
function isAdmin(user: User | Admin): user is Admin {
return 'role' in user && user.role === 'admin'
}
// Usage
function greetUser(user: User | Admin) {
if (isAdmin(user)) {
// TypeScript knows user is Admin here
console.log(`Admin ${user.name} has ${user.permissions.length} permissions`)
} else {
// TypeScript knows user is User here
console.log(`Hello ${user.name}`)
}
}When dealing with different states (loading, success, error), use discriminated unions instead of multiple boolean flags.
❌ Bad:
interface State {
loading: boolean
error: string | null
data: User[] | null
}✅ Good:
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string }
function UserList() {
const [state, setState] = useState<State>({ status: 'idle' })
// TypeScript enforces correct access
if (state.status === 'success') {
return <div>{state.data.map(user => ...)}</div>
}
if (state.status === 'error') {
return <div>Error: {state.error}</div>
}
return <div>Loading...</div>
}useState:
// Explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null)
// Type inference works when you have an initial value
const [count, setCount] = useState(0) // inferred as number
// Array of objects
const [users, setUsers] = useState<User[]>([])useRef:
// For DOM elements
const inputRef = useRef<HTMLInputElement>(null)
// For mutable values
const countRef = useRef<number>(0)
// Usage
function FocusInput() {
const inputRef = useRef<HTMLInputElement>(null)
const handleFocus = () => {
inputRef.current?.focus()
}
return <input ref={inputRef} />
}React event types can be tricky. Here's how to handle them properly:
import { ChangeEvent, FormEvent, MouseEvent } from 'react'
interface FormData {
email: string
password: string
}
function LoginForm() {
const [formData, setFormData] = useState<FormData>({
email: '',
password: ''
})
// Input change handler
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
// Form submit handler
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log(formData)
}
// Button click handler
const handleReset = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
setFormData({ email: '', password: '' })
}
return (
<form onSubmit={handleSubmit}>
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
<button type="submit">Login</button>
<button onClick={handleReset}>Reset</button>
</form>
)
}TypeScript provides powerful utility types. Use them!
interface User {
id: string
name: string
email: string
age: number
createdAt: Date
}
// Pick - Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Omit - Exclude specific properties
type UserWithoutDates = Omit<User, 'createdAt'>
// Partial - Make all properties optional
type UpdateUser = Partial<User>
// Required - Make all properties required
type RequiredUser = Required<User>
// Readonly - Make all properties readonly
type ImmutableUser = Readonly<User>
// Record - Create an object type with specific key-value types
type UserMap = Record<string, User>Create reusable generic components for better type safety:
interface SelectOption<T> {
value: T
label: string
}
interface SelectProps<T> {
options: SelectOption<T>[]
value: T
onChange: (value: T) => void
}
function Select<T>({ options, value, onChange }: SelectProps<T>) {
return (
<select
value={String(value)}
onChange={(e) => {
const option = options.find(
opt => String(opt.value) === e.target.value
)
if (option) onChange(option.value)
}}
>
{options.map((option, index) => (
<option key={index} value={String(option.value)}>
{option.label}
</option>
))}
</select>
)
}
// Usage with different types
const numberOptions = [
{ value: 1, label: 'One' },
{ value: 2, label: 'Two' },
]
const stringOptions = [
{ value: 'small', label: 'Small' },
{ value: 'large', label: 'Large' },
]
function App() {
const [num, setNum] = useState(1)
const [size, setSize] = useState('small')
return (
<>
<Select options={numberOptions} value={num} onChange={setNum} />
<Select options={stringOptions} value={size} onChange={setSize} />
</>
)
}Always type your API responses to ensure data integrity:
// Define response types
interface ApiResponse<T> {
data: T
message: string
success: boolean
}
interface User {
id: string
name: string
email: string
}
// Type-safe fetch function
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
const json: ApiResponse<User> = await response.json()
if (!json.success) {
throw new Error(json.message)
}
return json.data
}
// Usage with proper error handling
async function loadUser(userId: string) {
try {
const user = await fetchUser(userId)
console.log(user.name) // TypeScript knows user has 'name'
} catch (error) {
if (error instanceof Error) {
console.error(error.message)
}
}
}Use strict TypeScript settings in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}1. Type Assertions Without Validation
// ❌ Bad - Unsafe type assertion
const user = data as User
// ✅ Good - Validate first
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data
)
}
if (isUser(data)) {
// Safe to use as User
console.log(data.name)
}2. Forgetting Null Checks
// ❌ Bad
function getNameLength(user: User | null) {
return user.name.length // Error if user is null
}
// ✅ Good
function getNameLength(user: User | null) {
return user?.name.length ?? 0
}3. Misusing Type vs Interface
Use interface for object shapes, type for unions and complex types:
// ✅ Use interface for objects
interface User {
name: string
age: number
}
// ✅ Use type for unions
type Status = 'loading' | 'success' | 'error'
// ✅ Use type for complex types
type ApiResponse<T> = {
data: T
error?: string
} | {
data?: never
error: string
}TypeScript is a powerful tool that, when used correctly, dramatically improves your React development experience. By following these best practices, you'll:
Subscribe to our newsletter for in-depth guides, project ideas, and best practices to enhance your coding journey, delivered straight to your inbox all for FREE. Your next project deserves to stand out!