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).