Building Scalable React Applications with TypeScript

·1 min read

React applications can quickly become complex as they grow. In this post, I'll share my experience building scalable React applications with TypeScript, covering architecture patterns, code organization, and best practices.

Why TypeScript for React?

TypeScript brings static typing to JavaScript, which helps catch errors early and provides better developer experience through:

  • Compile-time error checking - Catch bugs before they reach production
  • Better IDE support - Enhanced autocomplete, refactoring, and navigation
  • Self-documenting code - Types serve as inline documentation
  • Safer refactoring - TypeScript ensures type safety during code changes

Project Structure

Here's a scalable folder structure I recommend for React TypeScript projects:

src/
├── components/       # Reusable UI components
├── pages/           # Page components
├── hooks/           # Custom React hooks
├── services/        # API calls and business logic
├── types/           # TypeScript type definitions
├── utils/           # Helper functions
└── contexts/        # React contexts

Component Architecture

I follow a component-based architecture with clear separation of concerns:

Presentation Components

interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger'
  size: 'small' | 'medium' | 'large'
  onClick: () => void
  children: React.ReactNode
  disabled?: boolean
}

export const Button: React.FC<ButtonProps> = ({
  variant,
  size,
  onClick,
  children,
  disabled = false
}) => {
  const baseClasses = 'font-medium rounded focus:outline-none'
  const variantClasses = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700'
  }

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  )
}

Container Components

Container components handle state management and business logic:

export const UserProfile: React.FC = () => {
  const { user, loading, error, updateUser } = useUser()

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />

  return <UserProfileView user={user} onUpdate={updateUser} />
}

Custom Hooks

Custom hooks encapsulate stateful logic and make it reusable:

interface UseApiReturn<T> {
  data: T | null
  loading: boolean
  error: string | null
  refetch: () => void
}

export function useApi<T>(url: string): UseApiReturn<T> {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  const fetchData = useCallback(async () => {
    try {
      setLoading(true)
      const response = await fetch(url)
      const result = await response.json()
      setData(result)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setLoading(false)
    }
  }, [url])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  return { data, loading, error, refetch: fetchData }
}

State Management

For complex state, I use a combination of:

  • React Context for global state
  • useReducer for complex local state
  • Custom hooks for encapsulating state logic
interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  notifications: Notification[]
}

type AppAction =
  | { type: 'SET_USER'; payload: User }
  | { type: 'TOGGLE_THEME' }
  | { type: 'ADD_NOTIFICATION'; payload: Notification }

const AppContext = createContext<{
  state: AppState
  dispatch: Dispatch<AppAction>
} | null>(null)

export const useAppContext = () => {
  const context = useContext(AppContext)
  if (!context) {
    throw new Error('useAppContext must be used within AppProvider')
  }
  return context
}

Testing Strategy

I follow a testing pyramid approach:

  1. Unit tests for utility functions and custom hooks
  2. Component tests using React Testing Library
  3. Integration tests for user workflows
  4. E2E tests for critical paths
describe('Button component', () => {
  it('renders with correct variant styles', () => {
    render(<Button variant="primary" onClick={jest.fn()}>Click me</Button>)

    const button = screen.getByRole('button')
    expect(button).toHaveClass('bg-blue-600')
  })

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn()
    render(<Button variant="primary" onClick={handleClick}>Click me</Button>)

    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
})

Performance Optimization

Key techniques for optimizing React TypeScript applications:

  • Code splitting with React.lazy and Suspense
  • Memoization with React.memo, useMemo, and useCallback
  • Bundle analysis to identify heavy dependencies
  • Tree shaking to eliminate unused code

Conclusion

Building scalable React applications with TypeScript requires thoughtful architecture, clear separation of concerns, and adherence to best practices. The type safety and developer experience benefits make it worth the initial learning curve.

The key is to start simple and gradually adopt more advanced patterns as your application grows in complexity.