TypeScript with React — Best Practices and Real-World Patterns
Advertisement
Introduction
Why This Matters
TypeScript in React catches prop mismatches, missing fields, and wrong event handler signatures at compile time instead of runtime. In large codebases, it makes refactoring safe and component APIs self-documenting — no need to check source code to know what props a component accepts.
Typing Function Components
Always define prop interfaces separately — they document the component's public API and enable reuse.
interface UserCardProps {
id: string
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
avatar?: string // Optional — has a sensible default
onEdit?: (id: string) => void
}
export function UserCard({ id, name, email, role, avatar = '/default-avatar.png', onEdit }: UserCardProps) {
return (
<div className="flex items-center gap-4 p-4 border rounded-lg">
<img src={avatar} alt={name} className="w-12 h-12 rounded-full" />
<div>
<h3 className="font-semibold">{name}</h3>
<p className="text-sm text-gray-500">{email}</p>
<span className="text-xs bg-gray-100 px-2 py-0.5 rounded">{role}</span>
</div>
{onEdit && (
<button onClick={() => onEdit(id)} className="ml-auto text-blue-600">Edit</button>
)}
</div>
)
}Typing Event Handlers
React exposes typed event interfaces for all DOM events.
'use client'
import { useState } from 'react'
export function SearchForm() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<string[]>([])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const data: { results: string[] } = await res.json()
setResults(data.results)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') setQuery('')
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
type="search"
className="flex-1 border rounded px-3 py-2"
placeholder="Search..."
/>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Search
</button>
</form>
)
}Generic Components
Generic components work with any data type while remaining fully type-safe.
interface SelectProps<T extends { id: string; label: string }> {
items: T[]
value: T | null
onChange: (item: T) => void
placeholder?: string
}
export function Select<T extends { id: string; label: string }>({
items,
value,
onChange,
placeholder = 'Select an option'
}: SelectProps<T>) {
return (
<select
value={value?.id ?? ''}
onChange={(e) => {
const selected = items.find(item => item.id === e.target.value)
if (selected) onChange(selected)
}}
className="border rounded px-3 py-2"
>
<option value="">{placeholder}</option>
{items.map(item => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
)
}
// Usage — TypeScript infers T as Category
type Category = { id: string; label: string; color: string }
const [category, setCategory] = useState<Category | null>(null)
// <Select items={categories} value={category} onChange={setCategory} />Typed Custom Hooks
import { useState, useCallback, useRef } from 'react'
interface AsyncState<T> {
data: T | null
loading: boolean
error: string | null
}
export function useAsync<T>(
asyncFn: (...args: any[]) => Promise<T>
): [AsyncState<T>, (...args: any[]) => Promise<void>] {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: false,
error: null
})
const mountedRef = useRef(true)
const execute = useCallback(async (...args: any[]) => {
setState({ data: null, loading: true, error: null })
try {
const data = await asyncFn(...args)
if (mountedRef.current) setState({ data, loading: false, error: null })
} catch (err) {
if (mountedRef.current) {
setState({ data: null, loading: false, error: err instanceof Error ? err.message : 'Unknown error' })
}
}
}, [asyncFn])
return [state, execute]
}
// Usage
const [{ data, loading, error }, fetchUser] = useAsync(
(id: string) => fetch(`/api/users/${id}`).then(r => r.json())
)Typing Refs
'use client'
import { useRef, useEffect } from 'react'
export function AutoFocusInput() {
// HTMLInputElement is the correct type for input refs
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<input
ref={inputRef}
type="text"
className="border rounded px-3 py-2"
placeholder="Auto-focused"
/>
)
}Discriminated Unions for Component Variants
type ButtonProps =
| { variant: 'primary'; onClick: () => void; children: React.ReactNode }
| { variant: 'link'; href: string; children: React.ReactNode }
| { variant: 'submit'; form?: string; children: React.ReactNode }
export function Button(props: ButtonProps) {
switch (props.variant) {
case 'primary':
return <button onClick={props.onClick} className="bg-blue-600 text-white px-4 py-2 rounded">{props.children}</button>
case 'link':
return <a href={props.href} className="text-blue-600 underline">{props.children}</a>
case 'submit':
return <button type="submit" form={props.form} className="bg-green-600 text-white px-4 py-2 rounded">{props.children}</button>
}
}Common Mistakes
- Using
anyinstead ofunknown—unknownforces type narrowing before use;anydisables type checking entirely - Typing children as
JSX.Element— useReact.ReactNodewhich includes strings, numbers, null, and arrays - Not leveraging discriminated unions for mutually exclusive prop combinations
- Casting with
asto silence type errors instead of fixing the underlying type mismatch
Best Practices
- Enable
strict: trueintsconfig.json— catches nullable access, implicit any, and more - Export prop interfaces alongside components so consumers can extend them
- Use
satisfiesoperator for type-safe object literals without widening the type - Prefer type inference over explicit annotations when TypeScript can infer correctly
Key Takeaways
- Define prop interfaces separately from component functions for readability and reusability
- Use React's typed event interfaces:
React.ChangeEvent<HTMLInputElement>,React.FormEvent<HTMLFormElement>, etc. - Generic components with type parameters (
function List<T>) create reusable, type-safe patterns React.ReactNodeis the correct type for thechildrenprop — it covers all renderable values- Discriminated unions model mutually exclusive prop combinations and enable exhaustive type checking
useRef<HTMLInputElement>(null)is the correct way to type DOM element refsunknownis always preferable toany— it forces you to narrow the type before using it- Enable TypeScript's
strictmode from project inception to catch the widest range of errors
Advertisement
Related reading
React Hooks - The Complete Guide with Real-World Examples6 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readJavaScript Array Methods — The Complete Cheatsheet for 20266 min readJavaScript Async/Await — Stop Writing Callback Hell5 min readJavaScript Data Types — Complete Guide with Type Coercion and TypeScript in 20268 min readJavaScript Gems — 15 Underused Features That Make Your Code Cleaner in 20269 min read