CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

Home/Web Development/TypeScript Best Practices for React Developers
Web Development

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.

Sabir KhaloufiSabir KhaloufiDecember 15, 20254 min read

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.

tsx
// 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:

tsx
// 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:

tsx
// 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:

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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 shape

Utility Types Worth Knowing

typescript
// 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 any for API responses — validate with Zod and infer types from the schema
  • as casts 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
#typescript#react#best practices#types#generics
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

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

On this page

  • Type Your Props Precisely
  • Discriminated Unions for Component States
  • Generic Components
  • Typing Custom Hooks
  • Avoid These Patterns
  • as Casts Are Type Lies
  • Non-Null Assertions Without Justification
  • any in API Responses
  • Utility Types Worth Knowing
  • Key Takeaways

Related Articles

Web Development

Building REST APIs with Node.js and Express: The Complete Guide

Learn to build production-ready REST APIs with Node.js and Express. Covers routing, middleware, validation, authentication, error handling, and testing best practices.

Sabir KhaloufiDecember 25, 20254 min read
Web Development

React Performance Optimization Techniques That Actually Work

Practical React performance techniques that make a real difference — memo, useMemo, useCallback, code splitting, virtualization, and when each one actually helps.

Sabir KhaloufiDecember 20, 20254 min read
Web Development

Understanding JWT Authentication in Modern Web Apps

A clear guide to JWT authentication — how tokens work, where to store them, refresh token patterns, security pitfalls, and practical implementation in Node.js and React.

Sabir KhaloufiDecember 10, 20254 min read