Back

React & TypeScript: Common Patterns for Cleaner Code

React & TypeScript: Common Patterns for Cleaner Code

When building React applications with TypeScript, you’ll encounter the same challenges repeatedly: how to type props correctly, handle events safely, and structure components for maximum reusability. These patterns aren’t just about satisfying the compiler—they’re about writing code that’s easier to understand, refactor, and maintain.

This article covers the most practical React TypeScript common patterns you’ll use daily. You’ll learn how to type components effectively, handle optional props, work with events and refs, leverage utility types, and use discriminated unions in reducers. Each pattern includes focused examples showing exactly why it improves your code.

Key Takeaways

  • Type props and state explicitly using interfaces or types for better IntelliSense and error catching
  • Handle optional and default props with TypeScript’s optional properties and default parameters
  • Use utility types like Pick and ComponentProps to reduce repetition and maintain consistency
  • Type events and refs properly for type-safe DOM interactions
  • Leverage discriminated unions in reducers for exhaustive action handling
  • Compose components with forwardRef to maintain type safety when passing refs

Typing Props and State

The foundation of React TypeScript patterns starts with properly typing your component props. This catches errors at compile time and provides better autocomplete in your editor.

Basic Props Pattern

interface ButtonProps {
  label: string
  onClick: () => void
  disabled?: boolean
}

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  )
}

This pattern ensures your components receive the correct data types. The interface clearly documents what props the component expects, making it self-documenting.

Typing State with useState

TypeScript usually infers state types correctly, but complex state benefits from explicit typing:

import { useState } from 'react'

interface User {
  id: string
  name: string
  email: string
}

function UserProfile() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(false) // Type inferred as boolean

  // TypeScript knows user might be null
  return user ? <div>{user.name}</div> : <div>Loading...</div>
}

Handling Optional and Default Props

React components often have optional configuration props. TypeScript’s optional properties combined with default parameters create a clean pattern:

interface CardProps {
  title: string
  description?: string
  variant?: 'primary' | 'secondary'
  className?: string
}

function Card({ 
  title, 
  description, 
  variant = 'primary',
  className = ''
}: CardProps) {
  return (
    <div className={`card card-${variant} ${className}`}>
      <h2>{title}</h2>
      {description && <p>{description}</p>}
    </div>
  )
}

This pattern makes components flexible while maintaining type safety. Required props are clear, and optional props have sensible defaults.

Typing Events and Refs

Event handlers and refs are common sources of TypeScript errors. These patterns eliminate guesswork:

Event Handler Pattern

import { useState, ChangeEvent, FormEvent } from 'react'

interface FormProps {
  onSubmit: (data: { email: string; password: string }) => void
}

function LoginForm({ onSubmit }: FormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value)
  }

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={handleEmailChange} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit">Login</button>
    </form>
  )
}

Ref Pattern

import { useRef, useEffect } from 'react'

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    // TypeScript knows inputRef.current might be null
    inputRef.current?.focus()
  }, [])

  return <input ref={inputRef} type="text" />
}

Using Utility Types

TypeScript’s utility types reduce code duplication and keep types synchronized across your application.

ComponentProps Pattern

Extract props from existing components without repeating type definitions:

import { ComponentProps } from 'react'

// Get all props that a button element accepts
type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary'
}

function StyledButton({ variant = 'primary', ...props }: ButtonProps) {
  return <button className={`btn-${variant}`} {...props} />
}

// Extract props from your own components
type MyButtonProps = ComponentProps<typeof StyledButton>

Pick Pattern

Select specific props when extending components:

interface UserData {
  id: string
  name: string
  email: string
  role: string
  lastLogin: Date
}

// Only need some properties for the card
type UserCardProps = Pick<UserData, 'name' | 'email'> & {
  onClick?: () => void
}

function UserCard({ name, email, onClick }: UserCardProps) {
  return (
    <div onClick={onClick}>
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  )
}

Composing Components with forwardRef

When building component libraries or wrapping native elements, forwardRef maintains type safety while exposing refs:

import { forwardRef, useRef, InputHTMLAttributes } from 'react'

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: string
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
        {error && <span className="error">{error}</span>}
      </div>
    )
  }
)

Input.displayName = 'Input'

// Usage maintains full type safety
function Form() {
  const inputRef = useRef<HTMLInputElement>(null)
  
  return <Input ref={inputRef} label="Email" type="email" />
}

Discriminated Unions in Reducers

Discriminated unions make reducer actions type-safe and enable exhaustive checking:

interface TodoState {
  items: Array<{ id: string; text: string; done: boolean }>
  filter: 'all' | 'active' | 'completed'
}

type TodoAction =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string }
  | { type: 'SET_FILTER'; payload: TodoState['filter'] }
  | { type: 'CLEAR_COMPLETED' }

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        items: [...state.items, {
          id: Date.now().toString(),
          text: action.payload,
          done: false
        }]
      }
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, done: !item.done }
            : item
        )
      }
    
    case 'SET_FILTER':
      return { ...state, filter: action.payload }
    
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        items: state.items.filter(item => !item.done)
      }
    
    default:
      // TypeScript ensures all cases are handled
      const exhaustive: never = action
      return state
  }
}

This pattern ensures you handle all action types. If you add a new action type, TypeScript will error until you handle it in the reducer.

React 19 Updates

With React 19, new capabilities like Actions (useActionState, useFormStatus), the use() API for consuming promises or context, and first-class support for Server Components make type safety even more important. When typing Actions, explicitly define the shape of FormData and the action’s return value so both server and client code stay in sync. The upcoming shift from forwardRef to passing ref as a standard prop also simplifies typing—treat ref like any other prop and let TypeScript ensure correctness across component boundaries.

Conclusion

These React TypeScript common patterns form the foundation of type-safe React applications. By typing props and state explicitly, handling events and refs correctly, and leveraging TypeScript’s utility types, you create code that’s both safer and more maintainable. The patterns work together—proper prop typing enables better component composition, while discriminated unions ensure exhaustive handling in complex state logic.

Start with basic prop typing and gradually incorporate utility types and advanced patterns as your application grows. The investment in proper typing pays off through better IDE support, fewer runtime errors, and clearer component contracts.

FAQs

Use interface when you might need to extend or merge declarations later. Use type for unions, intersections, or when working with utility types. Both work fine for basic props, so pick one approach and stay consistent.

Use ReactNode for the most flexibility: children: ReactNode. This accepts strings, numbers, elements, fragments, and arrays. For more specific cases, use ReactElement or specific element types.

Use generic functions that preserve the original component's prop types while adding new ones. ComponentProps utility type helps extract and extend existing component types without repetition.

Type function parameters, return types for exported functions, and complex state explicitly. Let TypeScript infer simple cases like useState with primitives or obvious event handlers to reduce verbosity.

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers