TypeScript with React — Best Practices and Real-World Patterns

Sanjeev SharmaSanjeev Sharma
5 min read

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 any instead of unknownunknown forces type narrowing before use; any disables type checking entirely
  • Typing children as JSX.Element — use React.ReactNode which includes strings, numbers, null, and arrays
  • Not leveraging discriminated unions for mutually exclusive prop combinations
  • Casting with as to silence type errors instead of fixing the underlying type mismatch

Best Practices

  • Enable strict: true in tsconfig.json — catches nullable access, implicit any, and more
  • Export prop interfaces alongside components so consumers can extend them
  • Use satisfies operator 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&lt;T>) create reusable, type-safe patterns
  • React.ReactNode is the correct type for the children prop — 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 refs
  • unknown is always preferable to any — it forces you to narrow the type before using it
  • Enable TypeScript's strict mode from project inception to catch the widest range of errors

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading