TypeScript Best Practices for React Developers
Essential TypeScript patterns and practices specifically for React development. Learn about proper typing, generics, utility types, and avoiding common pitfalls.
TypeScript has become essential for building robust React applications. This guide covers essential TypeScript patterns and practices specifically for React development, including proper typing, generics, utility types, and common pitfalls to avoid.
Setting Up TypeScript for React
Start with a proper TypeScript configuration for React projects:
{ "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "es6"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": [ "src" ]}
Typing React Components
Always use proper types for React components. Here are the most common patterns:
import React, { ReactNode, PropsWithChildren } from 'react';// Basic component props interfaceinterface ButtonProps { variant: 'primary' | 'secondary' | 'danger'; size?: 'small' | 'medium' | 'large'; disabled?: boolean; onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; children: ReactNode;}// Using React.FC (Function Component) - optional but explicitexport const Button: React.FC<ButtonProps> = ({ variant, size = 'medium', disabled = false, onClick, children}) => { return ( <button className={`btn btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick} > {children} </button> );};// Alternative: Direct function declaration (recommended)export function ButtonAlt({ variant, size = 'medium', disabled = false, onClick, children}: ButtonProps) { return ( <button className={`btn btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick} > {children} </button> );}// Component with children helperinterface CardProps extends PropsWithChildren { title: string; footer?: ReactNode;}export function Card({ title, footer, children }: CardProps) { return ( <div className="card"> <header>{title}</header> <main>{children}</main> {footer && <footer>{footer}</footer>} </div> );}
Advanced Props Patterns
Handle complex prop scenarios with discriminated unions and conditional types:
import React from 'react';// Discriminated unions for mutually exclusive propstype InputProps = { label: string; name: string; required?: boolean;} & ( | { type: 'text' | 'email' | 'password'; placeholder?: string; } | { type: 'number'; min?: number; max?: number; } | { type: 'select'; options: Array<{ value: string; label: string }>; });export function Input(props: InputProps) { const { label, name, required, type } = props; return ( <div className="input-group"> <label htmlFor={name}> {label} {required && '*'} </label> {type === 'select' ? ( <select name={name} required={required}> {props.options.map(option => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> ) : ( <input type={type} name={name} required={required} {...(type === 'text' || type === 'email' || type === 'password' ? { placeholder: props.placeholder } : {})} {...(type === 'number' ? { min: props.min, max: props.max } : {})} /> )} </div> );}// Optional vs Required props patterninterface BaseModalProps { isOpen: boolean; onClose: () => void; children: React.ReactNode;}interface ModalWithTitle extends BaseModalProps { title: string; subtitle?: string;}interface ModalWithoutTitle extends BaseModalProps { title?: never; subtitle?: never;}type ModalProps = ModalWithTitle | ModalWithoutTitle;export function Modal(props: ModalProps) { if (!props.isOpen) return null; return ( <div className="modal"> <div className="modal-content"> {props.title && ( <header> <h2>{props.title}</h2> {props.subtitle && <p>{props.subtitle}</p>} </header> )} <main>{props.children}</main> </div> </div> );}
Typing Hooks
Properly type React hooks for better type safety and IDE support:
import React, { useState, useEffect, useRef, useCallback } from 'react';// State with explicit typesexport function useCounter(initialValue: number = 0) { const [count, setCount] = useState<number>(initialValue); const increment = useCallback(() => setCount(prev => prev + 1), []); const decrement = useCallback(() => setCount(prev => prev - 1), []); const reset = useCallback(() => setCount(initialValue), [initialValue]); return { count, increment, decrement, reset } as const;}// Generic hook for API callsinterface ApiState<T> { data: T | null; loading: boolean; error: string | null;}export function useApi<T>(url: string): ApiState<T> { const [state, setState] = useState<ApiState<T>>({ data: null, loading: true, error: null }); useEffect(() => { let isMounted = true; fetch(url) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then((data: T) => { if (isMounted) { setState({ data, loading: false, error: null }); } }) .catch(error => { if (isMounted) { setState({ data: null, loading: false, error: error.message }); } }); return () => { isMounted = false; }; }, [url]); return state;}// Typed useRef examplesexport function FocusInput() { const inputRef = useRef<HTMLInputElement>(null); const divRef = useRef<HTMLDivElement>(null); const focusInput = () => { inputRef.current?.focus(); }; useEffect(() => { if (divRef.current) { console.log('Div height:', divRef.current.offsetHeight); } }, []); return ( <div ref={divRef}> <input ref={inputRef} type="text" /> <button onClick={focusInput}>Focus Input</button> </div> );}
Event Handling Types
TypeScript provides specific types for different HTML events:
import React, { FormEvent, ChangeEvent, KeyboardEvent, MouseEvent } from 'react';interface FormData { username: string; email: string; age: number;}export function ContactForm() { const [formData, setFormData] = React.useState<FormData>({ username: '', email: '', age: 0 }); // Form submission const handleSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); console.log('Form submitted:', formData); }; // Input changes const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => { const { name, value, type } = event.target; setFormData(prev => ({ ...prev, [name]: type === 'number' ? parseInt(value, 10) || 0 : value })); }; // Keyboard events const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { if (event.key === 'Enter') { event.preventDefault(); // Handle Enter key } }; // Mouse events const handleButtonClick = (event: MouseEvent<HTMLButtonElement>) => { console.log('Button clicked at:', event.clientX, event.clientY); }; return ( <form onSubmit={handleSubmit}> <input type="text" name="username" value={formData.username} onChange={handleInputChange} onKeyDown={handleKeyDown} placeholder="Username" /> <input type="email" name="email" value={formData.email} onChange={handleInputChange} placeholder="Email" /> <input type="number" name="age" value={formData.age} onChange={handleInputChange} placeholder="Age" /> <button type="submit" onClick={handleButtonClick}> Submit </button> </form> );}
Utility Types for React
Leverage TypeScript utility types to create flexible and reusable component interfaces:
import React, { ComponentProps } from 'react';// Extract props from existing componentstype ButtonProps = ComponentProps<'button'>;type DivProps = ComponentProps<'div'>;// Extend native element propsinterface CustomButtonProps extends Omit<ButtonProps, 'type'> { variant: 'primary' | 'secondary'; loading?: boolean;}export function CustomButton({ variant, loading, children, disabled, ...buttonProps }: CustomButtonProps) { return ( <button {...buttonProps} disabled={disabled || loading} className={`btn btn-${variant} ${loading ? 'loading' : ''}`} > {loading ? 'Loading...' : children} </button> );}// Pick specific propsinterface User { id: number; name: string; email: string; password: string; role: 'admin' | 'user'; createdAt: Date;}// Only pick safe props for displaytype SafeUser = Pick<User, 'id' | 'name' | 'email' | 'role'>;// Or omit sensitive propstype PublicUser = Omit<User, 'password'>;interface UserCardProps { user: SafeUser; onEdit?: (userId: number) => void;}export function UserCard({ user, onEdit }: UserCardProps) { return ( <div className="user-card"> <h3>{user.name}</h3> <p>{user.email}</p> <span className={`role role-${user.role}`}>{user.role}</span> {onEdit && ( <button onClick={() => onEdit(user.id)}> Edit </button> )} </div> );}// Partial for optional updatesinterface UpdateUserData extends Partial<Pick<User, 'name' | 'email' | 'role'>> {}function updateUser(id: number, updates: UpdateUserData) { // Implementation here console.log(`Updating user ${id} with:`, updates);}// Record for mapped typestype UserPermissions = Record<User['role'], string[]>;const permissions: UserPermissions = { admin: ['read', 'write', 'delete'], user: ['read']};
Generic Components
Create reusable components with generics for better type safety:
import React from 'react';// Generic list componentinterface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string | number; emptyMessage?: string;}export function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items found' }: ListProps<T>) { if (items.length === 0) { return <div className="empty-list">{emptyMessage}</div>; } return ( <ul className="list"> {items.map((item, index) => ( <li key={keyExtractor(item)}> {renderItem(item, index)} </li> ))} </ul> );}// Usage exampleinterface Product { id: number; name: string; price: number;}export function ProductList({ products }: { products: Product[] }) { return ( <List items={products} keyExtractor={product => product.id} renderItem={(product) => ( <div> <h3>{product.name}</h3> <p>${product.price}</p> </div> )} emptyMessage="No products available" /> );}// Generic form field componentinterface FormFieldProps<T extends string | number> { name: string; label: string; value: T; onChange: (name: string, value: T) => void; type: T extends string ? 'text' | 'email' | 'password' : 'number'; required?: boolean;}export function FormField<T extends string | number>({ name, label, value, onChange, type, required = false}: FormFieldProps<T>) { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = type === 'number' ? (parseInt(e.target.value, 10) as T) : (e.target.value as T); onChange(name, newValue); }; return ( <div className="form-field"> <label htmlFor={name}> {label} {required && '*'} </label> <input id={name} name={name} type={type} value={value} onChange={handleChange} required={required} /> </div> );}
Common TypeScript Pitfalls
Avoid These Common Mistakes
// ❌ Bad: Using anyfunction BadComponent({ data }: { data: any }) { return <div>{data.someProperty}</div>;}// ✅ Good: Proper typinginterface ComponentData { someProperty: string; optional?: number;}function GoodComponent({ data }: { data: ComponentData }) { return <div>{data.someProperty}</div>;}// ❌ Bad: Inline function declarationsfunction BadEventHandling() { return ( <button onClick={(e) => { // This creates a new function on every render console.log('Clicked'); }}> Click me </button> );}// ✅ Good: Proper event handlersfunction GoodEventHandling() { const handleClick = React.useCallback((e: React.MouseEvent) => { console.log('Clicked'); }, []); return <button onClick={handleClick}>Click me</button>;}// ❌ Bad: Ignoring null/undefinedfunction BadNullHandling({ user }: { user: User | null }) { return <div>{user.name}</div>; // TypeScript error!}// ✅ Good: Proper null checkingfunction GoodNullHandling({ user }: { user: User | null }) { if (!user) { return <div>No user found</div>; } return <div>{user.name}</div>;}// ✅ Alternative: Optional chainingfunction AlternativeNullHandling({ user }: { user: User | null }) { return <div>{user?.name ?? 'No user found'}</div>;}
Best Practices Summary
Enable strict mode in tsconfig.json for better type safety and error catching.
Define interfaces before implementing components for better design and documentation.
Use generics for reusable components but don't over-engineer simple cases.
Leverage Pick, Omit, Partial, and other utility types for flexible interfaces.