Building Scalable React Applications with TypeScript
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:
- Unit tests for utility functions and custom hooks
- Component tests using React Testing Library
- Integration tests for user workflows
- 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.