Back to Blog

TypeScript Best Practices for React Applications

Discover advanced TypeScript patterns and best practices for building type-safe React applications.

January 5, 2024
Mike Johnson
12 min read
TypeScriptReactBest Practices

TypeScript Best Practices for React Applications

TypeScript has become the standard for building scalable React applications. Let's explore advanced patterns and best practices that will level up your TypeScript game.

Type Safety from the Ground Up

1. Strict Mode is Your Friend

Always enable strict mode in your tsconfig.json:

{

"compilerOptions": {

"strict": true,

"strictNullChecks": true,

"strictFunctionTypes": true,

"noImplicitAny": true,

"noImplicitReturns": true,

"noFallthroughCasesInSwitch": true

}

}

2. Component Props with Discriminated Unions

Use discriminated unions for components with different behaviors:

type ButtonProps =

| { variant: "primary"; onClick: () => void; children: React.ReactNode }

| { variant: "link"; href: string; children: React.ReactNode }

function Button(props: ButtonProps) {

if (props.variant === "primary") {

return

}

return {props.children}

}

// Usage - TypeScript will enforce the correct props

3. Generic Components

Create reusable components with generics:

interface ListProps {

items: T[]

renderItem: (item: T, index: number) => React.ReactNode

keyExtractor?: (item: T, index: number) => string | number

}

function List({ items, renderItem, keyExtractor }: ListProps) {

return (

    {items.map((item, index) => (

  • {renderItem(item, index)}

  • ))}

)

}

// Usage with full type safety

;

items={users}

renderItem={(user) => {user.name}}

keyExtractor={(user) => user.id}

/>

Advanced Patterns

Custom Hooks with Type Inference

function useLocalStorage(

key: string,

initialValue: T

): [T, (value: T | ((val: T) => T)) => void] {

const [storedValue, setStoredValue] = useState(() => {

try {

const item = window.localStorage.getItem(key)

return item ? JSON.parse(item) : initialValue

} catch (error) {

console.error(Error reading localStorage key "${key}":, error)

return initialValue

}

})

const setValue = useCallback(

(value: T | ((val: T) => T)) => {

try {

const valueToStore =

value instanceof Function ? value(storedValue) : value

setStoredValue(valueToStore)

window.localStorage.setItem(key, JSON.stringify(valueToStore))

} catch (error) {

console.error(Error setting localStorage key "${key}":, error)

}

},

[key, storedValue]

)

return [storedValue, setValue]

}

// Usage with automatic type inference

const [user, setUser] = useLocalStorage('user', { name: '', email: '' })

// user is typed as { name: string, email: string }

Conditional Types for Props

type InputProps = {

value: T

onChange: (value: T) => void

type: T extends string ? 'text' | 'email' | 'password' : 'number'

}

function Input(props: InputProps) {

return (

type={props.type}

value={props.value}

onChange={(e) => {

const value =

props.type === 'number'

? (Number(e.target.value) as T)

: (e.target.value as T)

props.onChange(value)

}}

/>

)

}

Event Handlers with Proper Typing

interface FormData {

email: string

password: string

}

function LoginForm() {

const [formData, setFormData] = useState({

email: '',

password: '',

})

const handleInputChange = useCallback(

(event: React.ChangeEvent) => {

const { name, value } = event.target

setFormData((prev) => ({

...prev,

[name]: value,

}))

},

[]

)

const handleSubmit = useCallback(

(event: React.FormEvent) => {

event.preventDefault()

// Form submission logic

},

[formData]

)

return (

name="email"

type="email"

value={formData.email}

onChange={handleInputChange}

/>

name="password"

type="password"

value={formData.password}

onChange={handleInputChange}

/>

)

}

Type Utilities and Helpers

Creating Utility Types

// Extract props from any component

type ComponentProps = T extends React.ComponentType ? P : never

// Make all properties optional

type PartialProps = {

[P in keyof T]?: T[P]

}

// Pick specific properties

type ApiUser = {

id: string

name: string

email: string

avatar: string

createdAt: string

}

type UserCard = Pick

type UserList = Pick

Extending HTML Elements

interface ButtonProps extends React.ButtonHTMLAttributes {

variant?: 'primary' | 'secondary' | 'danger'

size?: 'sm' | 'md' | 'lg'

}

const Button = React.forwardRef(

({ variant = 'primary', size = 'md', className, ...props }, ref) => {

return (

ref={ref}

className={cn(

'rounded px-4 py-2',

{

'bg-blue-500 text-white': variant === 'primary',

'bg-gray-500 text-white': variant === 'secondary',

'bg-red-500 text-white': variant === 'danger',

},

className

)}

{...props}

/>

)

}

)

Button.displayName = 'Button'

Error Boundaries with TypeScript

interface ErrorBoundaryState {

hasError: boolean

error?: Error

}

class ErrorBoundary extends React.Component<

React.PropsWithChildren<{}>,

ErrorBoundaryState

> {

constructor(props: React.PropsWithChildren<{}>) {

super(props)

this.state = { hasError: false }

}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {

return { hasError: true, error }

}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {

console.error('Error caught by boundary:', error, errorInfo)

}

render() {

if (this.state.hasError) {

return (

Something went wrong

{this.state.error?.message}

)

}

return this.props.children

}

}

Testing with TypeScript

import { fireEvent, render, screen } from '@testing-library/react'

import { Button } from './Button'

describe('Button', () => {

it('calls onClick when clicked', () => {

const handleClick = jest.fn()

render(

)

fireEvent.click(screen.getByRole('button'))

expect(handleClick).toHaveBeenCalledTimes(1)

})

it('renders with correct variant styles', () => {

render()

const button = screen.getByRole('button')

expect(button).toHaveClass('bg-red-500')

})

})

Performance Optimizations

Memoization with TypeScript

interface ExpensiveComponentProps {

data: ComplexData[]

onItemClick: (id: string) => void

}

const ExpensiveComponent = React.memo(

({ data, onItemClick }) => {

const processedData = useMemo(

() => data.map((item) => ({ ...item, processed: true })),

[data]

)

const handleClick = useCallback(

(id: string) => onItemClick(id),

[onItemClick]

)

return (

{processedData.map((item) => (

handleClick(item.id)}>

{item.name}

))}

)

}

)

Conclusion

TypeScript transforms React development by catching errors at compile time and providing excellent IDE support. By following these patterns and best practices, you'll build more maintainable, scalable, and reliable applications.

Key takeaways:

  • Use strict mode and embrace type safety
  • Leverage discriminated unions for component variants
  • Create reusable generic components
  • Properly type event handlers and custom hooks
  • Use utility types to keep your code DRY
  • Don't forget about performance optimizations
  • Remember: the goal is not just to satisfy the TypeScript compiler, but to write code that's easier to understand, maintain, and refactor.