Best Practices9 min read · Updated 2025-06-01

TypeScript with React: Best Practices for 2025

How to use TypeScript effectively in React projects — typing props, hooks, events, context, and refs correctly, with common mistakes and how to avoid them.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

TypeScript with React: Best Practices for 2025

Typing Component Props

// Interface for object shapes (recommended)
interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'danger'
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
  disabled?: boolean
  children?: React.ReactNode
}

// FC type — use sparingly; it always includes children const Button: React.FC<ButtonProps> = ({ label, onClick, variant = 'primary' }) => ( <button className={variant} onClick={onClick}>{label}</button> )

// Preferred: plain function (better for generics, no implicit children) function Button({ label, onClick, variant = 'primary' }: ButtonProps) { return <button className={variant} onClick={onClick}>{label}</button> }

Typing useState

// TypeScript infers from initial value
const [count, setCount] = useState(0)        // number
const [name, setName] = useState('')          // string

// Explicit generic when initial value doesn't reflect full type const [user, setUser] = useState<User | null>(null) // User | null, not just null

// For complex state, define the type explicitly interface FormState { email: string password: string errors: Record<string, string> } const [form, setForm] = useState<FormState>({ email: '', password: '', errors: {} })

Typing useRef

// DOM ref — starts null, TypeScript knows it after mount
const inputRef = useRef<HTMLInputElement>(null)

function focusInput() { inputRef.current?.focus() // optional chain handles null safely // or: inputRef.current!.focus() if you're certain it's mounted }

// Mutable value ref — no null, no DOM const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) timerRef.current = setTimeout(() => {}, 1000)

Typing useReducer

type CartItem = { id: string; name: string; quantity: number }

interface CartState { items: CartItem[] total: number }

type CartAction = | { type: 'ADD'; item: CartItem } | { type: 'REMOVE'; id: string } | { type: 'CLEAR' }

function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'ADD': return { ...state, items: [...state.items, action.item] } case 'REMOVE': return { ...state, items: state.items.filter(i => i.id !== action.id) } case 'CLEAR': return { items: [], total: 0 } } }

const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 }) dispatch({ type: 'ADD', item: { id: '1', name: 'Hat', quantity: 1 } }) // ✓ dispatch({ type: 'REMOVE', id: '1' }) // ✓ dispatch({ type: 'REMOVE' }) // ❌ Error: missing 'id'

Typing Context

interface AuthContextType {
  user: User | null
  login: (credentials: Credentials) => Promise<void>
  logout: () => void
  isLoading: boolean
}

// Don't use undefined as default — makes every consumer check for it const AuthContext = React.createContext<AuthContextType | null>(null)

export function useAuth(): AuthContextType { const ctx = useContext(AuthContext) if (!ctx) throw new Error('useAuth must be used inside AuthProvider') return ctx // return type is AuthContextType, not null — no more ! assertions }

Typing Event Handlers

// Input events
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEmail(e.target.value)
}

// Form submit const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() // process form }

// Generic event (when you don't need event details) const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => { e.stopPropagation() }

// Keyboard event const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') submitForm() }

Typing Children Patterns

// Most flexible — accepts anything React can render
interface Props {
  children: React.ReactNode
}

// Enforce a single React element interface Props { children: React.ReactElement }

// Render prop pattern interface DataTableProps<T> { data: T[] renderRow: (item: T, index: number) => React.ReactNode }

function DataTable<T>({ data, renderRow }: DataTableProps<T>) { return <table>{data.map(renderRow)}</table> }

Common Mistakes to Avoid

// ❌ Don't use React.FC — it implicitly adds children and breaks with generics
const MyList: React.FC = () => <ul />

// ✅ Plain function function MyList() { return <ul /> }

// ❌ Don't use any for event handlers const handle = (e: any) => e.target.value

// ✅ Use specific event type const handle = (e: React.ChangeEvent<HTMLInputElement>) => e.target.value

// ❌ Don't assert ! when useRef might be null const el = ref.current!.getBoundingClientRect()

// ✅ Check in useEffect after mount useEffect(() => { if (ref.current) { const rect = ref.current.getBoundingClientRect() } }, [])

Practice TypeScript + React questions at [JSPrep Pro](/auth).

Put This Into Practice

Reading articles is passive. JSPrep Pro makes you actively recall, predict output, and get AI feedback.

Start Free →Browse All Questions

Related Articles

Deep Dive
We Built a RAG-Powered AI Question Engine Into a JavaScript Interview Platform — Here's Exactly How It Works
12 min read
Build Systems
Monorepo with Turborepo vs Nx: The Complete Comparison (2025)
9 min read
Core Concepts
map() vs forEach() in JavaScript: Which One to Use and Why It Matters
7 min read