TypeScript Best Practices for React Developers
Practical TypeScript patterns for React — covering component typing, generic components, custom hook types, discriminated unions, and the mistakes that cause runtime errors despite type checking.
TypeScript adds a compiler — it doesn't automatically make your React code correct. A codebase littered with any, unchecked as casts, and ! non-null assertions has types in name only. Real TypeScript proficiency means using the type system to catch real bugs, not just satisfying the compiler.
These are the patterns that actually matter in production React applications.
Type Your Props Precisely
The most common mistake: using any or overly broad types for props.
// BAD — type system does nothing for you
interface ButtonProps {
onClick: any
variant: string
children: any
}
// GOOD — precise types catch mistakes at compile time
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
interface ButtonProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
variant: ButtonVariant
children: React.ReactNode
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}With the precise version, passing variant="primry" (typo) is a compile error. With string, it silently fails at runtime.
Discriminated Unions for Component States
One of the most powerful patterns for modeling complex component states:
// Instead of multiple optional fields that can be in impossible combinations:
interface DataState {
loading: boolean
data?: User[]
error?: string
}
// Problem: what does { loading: false, data: undefined, error: undefined } mean?
// Use a discriminated union — each state is unambiguous:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function UserList() {
const [state, setState] = useState<AsyncState<User[]>>({ status: 'idle' })
// TypeScript narrows the type in each branch:
if (state.status === 'loading') return <Spinner />
if (state.status === 'error') return <ErrorMessage message={state.error} />
if (state.status === 'idle') return <button onClick={fetchUsers}>Load Users</button>
// state.status === 'success' is the only remaining case
// TypeScript knows state.data is User[] here — no undefined check needed
return <ul>{state.data.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}Generic Components
When you find yourself writing nearly identical components for different data types, use generics:
// Without generics — duplicate code for every type
interface UserSelectProps {
options: User[]
value: User | null
onChange: (value: User | null) => void
}
interface PostSelectProps {
options: Post[]
value: Post | null
onChange: (value: Post | null) => void
}
// With generics — one component, fully type-safe for any type
interface SelectProps<T> {
options: T[]
value: T | null
onChange: (value: T | null) => void
getLabel: (item: T) => string
getKey: (item: T) => string
}
function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
return (
<select
value={value ? getKey(value) : ''}
onChange={e => {
const selected = options.find(o => getKey(o) === e.target.value) ?? null
onChange(selected)
}}
>
<option value="">Select...</option>
{options.map(option => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Usage — TypeScript infers T = User automatically
<Select
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={user => user.name}
getKey={user => user.id}
/>Typing Custom Hooks
Custom hooks deserve precise return types:
// Return tuple type — matches useState pattern
function useToggle(initial = false): [boolean, () => void, () => void, () => void] {
const [value, setValue] = useState(initial)
const on = useCallback(() => setValue(true), [])
const off = useCallback(() => setValue(false), [])
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle, on, off]
}
// Return object type — better for hooks with many values
interface UseFetchResult<T> {
data: T | null
loading: boolean
error: string | null
refetch: () => void
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
const fetch_ = useCallback(async () => {
setState({ status: 'loading' })
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as T
setState({ status: 'success', data })
} catch (e) {
setState({ status: 'error', error: e instanceof Error ? e.message : 'Unknown error' })
}
}, [url])
useEffect(() => { fetch_() }, [fetch_])
return {
data: state.status === 'success' ? state.data : null,
loading: state.status === 'loading',
error: state.status === 'error' ? state.error : null,
refetch: fetch_,
}
}Avoid These Patterns
as Casts Are Type Lies
// This compiles but crashes at runtime if the element isn't an input
const input = document.getElementById('search') as HTMLInputElement
input.value = 'hello' // Crashes if element is a div
// Better: use a type guard
const element = document.getElementById('search')
if (element instanceof HTMLInputElement) {
element.value = 'hello' // TypeScript knows it's HTMLInputElement here
}Non-Null Assertions Without Justification
// This compiles but crashes if user is undefined
const username = user!.name
// Better: handle the undefined case
const username = user?.name ?? 'Anonymous'any in API Responses
// BAD — you lose all type safety after this fetch
const data: any = await fetch('/api/users').then(r => r.json())
// GOOD — validate and type the response
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const UsersSchema = z.array(UserSchema)
type User = z.infer<typeof UserSchema>
const raw = await fetch('/api/users').then(r => r.json())
const users = UsersSchema.parse(raw) // Throws if shape doesn't match
// users is typed as User[] with guaranteed shapeUtility Types Worth Knowing
// Pick — select specific fields
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>
// Omit — exclude specific fields
type UserWithoutPassword = Omit<User, 'password' | 'salt'>
// Partial — all fields optional (useful for update payloads)
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>
// Required — all fields required
type RequiredConfig = Required<Config>
// Record — object with specific key/value types
const statusMessages: Record<'active' | 'inactive' | 'banned', string> = {
active: 'Your account is active',
inactive: 'Your account is inactive',
banned: 'Your account has been banned',
}
// ReturnType — extract return type from a function
type FetchResult = ReturnType<typeof fetchUser>Key Takeaways
- Discriminated unions model state more accurately than optional fields
- Generic components eliminate duplicate type definitions
- Never use
anyfor API responses — validate with Zod and infer types from the schema ascasts and!operators are red flags — use type guards instead- Return types on custom hooks make them easier to use and harder to misuse
- TypeScript's utility types (
Pick,Omit,Partial,Record) cover most common type transformations

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.