← Back to Blog
Building Scalable React Applications

Building Scalable React Applications

Over the past decade, I've built React applications ranging from small marketing sites to enterprise platforms. The difference between a codebase that scales gracefully and one that becomes unmaintainable often comes down to early architectural decisions.

When I started building Catalyst PSA—a comprehensive professional services automation platform—I had to make critical decisions about architecture that would support a growing team and an expanding feature set. Here's what I learned from building large-scale React applications that actually work in production.

Component Architecture Principles

The foundation of a scalable React application is a well-designed component architecture. This goes far beyond just "breaking things into small components."

1. The Single Responsibility Principle

Each component should have one clear purpose. When a component starts doing too much, it's time to break it down.

Here's an example from a real project management system:

// Bad: Too many responsibilities
function ProjectDashboard() {
  const [projects, setProjects] = useState([])
  const [filters, setFilters] = useState({})
  const [user, setUser] = useState(null)
  const [notifications, setNotifications] = useState([])
 
  useEffect(() => {
    // Fetch projects
    // Fetch user
    // Fetch notifications
    // Apply filters
    // Set up WebSocket
  }, [])
 
  return (
    <div>
      {/* Hundreds of lines of JSX */}
    </div>
  )
}
 
// Good: Single responsibilities
function ProjectDashboard() {
  return (
    <DashboardLayout>
      <DashboardHeader />
      <ProjectFilters />
      <ProjectList />
      <NotificationCenter />
    </DashboardLayout>
  )
}

2. Component Composition Patterns

Composition is React's superpower. Here's a pattern I use extensively in enterprise applications:

// Container/Presenter pattern for complex features
// Container handles data and business logic
function ProjectListContainer() {
  const { projects, loading, error } = useProjects()
  const { filters, setFilters } = useProjectFilters()
 
  if (loading) return <ProjectListSkeleton />
  if (error) return <ErrorState error={error} />
 
  return (
    <ProjectList
      projects={projects}
      filters={filters}
      onFilterChange={setFilters}
    />
  )
}
 
// Presenter focuses purely on rendering
function ProjectList({ projects, filters, onFilterChange }) {
  return (
    <div className="space-y-4">
      <FilterBar filters={filters} onChange={onFilterChange} />
      <div className="grid grid-cols-3 gap-4">
        {projects.map(project => (
          <ProjectCard key={project.id} project={project} />
        ))}
      </div>
    </div>
  )
}

3. Compound Components for Flexibility

For components that need to work together, the compound component pattern provides excellent flexibility:

// Flexible, composable card component
function Card({ children, className }) {
  return <div className={`card ${className}`}>{children}</div>
}
 
Card.Header = function CardHeader({ children }) {
  return <div className="card-header">{children}</div>
}
 
Card.Body = function CardBody({ children }) {
  return <div className="card-body">{children}</div>
}
 
Card.Footer = function CardFooter({ children }) {
  return <div className="card-footer">{children}</div>
}
 
// Usage - consumers can compose as needed
function ProjectCard({ project }) {
  return (
    <Card>
      <Card.Header>
        <h3>{project.title}</h3>
        <StatusBadge status={project.status} />
      </Card.Header>
      <Card.Body>
        <p>{project.description}</p>
        <ProjectMetrics metrics={project.metrics} />
      </Card.Body>
      <Card.Footer>
        <Button>View Details</Button>
      </Card.Footer>
    </Card>
  )
}

State Management at Scale

Choosing the right state management approach is critical. I've used all the major solutions in production, and here's when to use each.

Local State: useState

Start here. Most state should be local to the component that needs it.

function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
 
  const handleSearch = async (value: string) => {
    setQuery(value)
    const data = await searchAPI(value)
    setResults(data)
  }
 
  return (
    <div>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
      />
      <SearchResults results={results} />
    </div>
  )
}

Context: For Widely-Shared State

Context is perfect for theme, authentication, and feature flags—things that many components need but don't change often.

// auth-context.tsx
interface AuthContext {
  user: User | null
  login: (credentials: Credentials) => Promise<void>
  logout: () => Promise<void>
  isAuthenticated: boolean
}
 
const AuthContext = createContext<AuthContext | undefined>(undefined)
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
 
  const login = async (credentials: Credentials) => {
    const userData = await authAPI.login(credentials)
    setUser(userData)
    localStorage.setItem('token', userData.token)
  }
 
  const logout = async () => {
    await authAPI.logout()
    setUser(null)
    localStorage.removeItem('token')
  }
 
  const value = {
    user,
    login,
    logout,
    isAuthenticated: !!user,
  }
 
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
 
export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

Zustand: For Complex Global State

For Catalyst PSA, I used Zustand extensively. It's lightweight, doesn't require providers, and has excellent TypeScript support.

// stores/project-store.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
 
interface ProjectState {
  projects: Project[]
  selectedProject: Project | null
  filters: ProjectFilters
 
  // Actions
  setProjects: (projects: Project[]) => void
  selectProject: (id: string) => void
  updateFilters: (filters: Partial<ProjectFilters>) => void
  addProject: (project: Project) => Promise<void>
  updateProject: (id: string, updates: Partial<Project>) => Promise<void>
  deleteProject: (id: string) => Promise<void>
}
 
export const useProjectStore = create<ProjectState>()(
  devtools(
    persist(
      (set, get) => ({
        projects: [],
        selectedProject: null,
        filters: {},
 
        setProjects: (projects) => set({ projects }),
 
        selectProject: (id) => {
          const project = get().projects.find(p => p.id === id)
          set({ selectedProject: project || null })
        },
 
        updateFilters: (newFilters) =>
          set((state) => ({
            filters: { ...state.filters, ...newFilters }
          })),
 
        addProject: async (project) => {
          const created = await projectAPI.create(project)
          set((state) => ({
            projects: [...state.projects, created]
          }))
        },
 
        updateProject: async (id, updates) => {
          const updated = await projectAPI.update(id, updates)
          set((state) => ({
            projects: state.projects.map(p =>
              p.id === id ? updated : p
            )
          }))
        },
 
        deleteProject: async (id) => {
          await projectAPI.delete(id)
          set((state) => ({
            projects: state.projects.filter(p => p.id !== id),
            selectedProject: state.selectedProject?.id === id
              ? null
              : state.selectedProject
          }))
        },
      }),
      { name: 'project-store' }
    )
  )
)
 
// Usage in components
function ProjectList() {
  const { projects, filters, updateFilters } = useProjectStore()
 
  return (
    <div>
      <FilterBar filters={filters} onChange={updateFilters} />
      {projects.map(project => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  )
}

When to Use Redux

I use Redux when:

  • The application has complex state interactions
  • You need time-travel debugging
  • The team is already familiar with Redux

For most new projects, Zustand is my preference—it's simpler and more flexible.

Custom Hooks: Extracting Reusable Logic

Custom hooks are one of React's most powerful features for code reuse. Here are patterns I use constantly.

Data Fetching Hook

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}
 
function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: UseQueryOptions<T> = {}
) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)
 
  const { onSuccess, onError, enabled = true } = options
 
  useEffect(() => {
    if (!enabled) return
 
    let cancelled = false
 
    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)
        const result = await fetcher()
 
        if (!cancelled) {
          setData(result)
          onSuccess?.(result)
        }
      } catch (err) {
        if (!cancelled) {
          const error = err instanceof Error ? err : new Error('Unknown error')
          setError(error)
          onError?.(error)
        }
      } finally {
        if (!cancelled) {
          setLoading(false)
        }
      }
    }
 
    fetchData()
 
    return () => {
      cancelled = true
    }
  }, [key, enabled])
 
  return { data, loading, error }
}
 
// Usage
function ProjectDetails({ id }: { id: string }) {
  const { data: project, loading, error } = useQuery(
    `project-${id}`,
    () => projectAPI.getById(id),
    {
      onSuccess: (project) => {
        analytics.track('project_viewed', { id: project.id })
      }
    }
  )
 
  if (loading) return <Spinner />
  if (error) return <Error error={error} />
  if (!project) return <NotFound />
 
  return <ProjectView project={project} />
}

Form Handling Hook

interface UseFormOptions<T> {
  initialValues: T
  validate?: (values: T) => Partial<Record<keyof T, string>>
  onSubmit: (values: T) => Promise<void> | void
}
 
function useForm<T extends Record<string, any>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>) {
  const [values, setValues] = useState<T>(initialValues)
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
  const [submitting, setSubmitting] = useState(false)
 
  const handleChange = (name: keyof T, value: any) => {
    setValues(prev => ({ ...prev, [name]: value }))
 
    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: undefined }))
    }
  }
 
  const handleBlur = (name: keyof T) => {
    setTouched(prev => ({ ...prev, [name]: true }))
 
    // Validate field on blur
    if (validate) {
      const fieldErrors = validate(values)
      if (fieldErrors[name]) {
        setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }))
      }
    }
  }
 
  const handleSubmit = async (e?: React.FormEvent) => {
    e?.preventDefault()
 
    // Validate all fields
    const validationErrors = validate?.(values) || {}
    setErrors(validationErrors)
 
    // Mark all fields as touched
    const allTouched = Object.keys(values).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {}
    )
    setTouched(allTouched)
 
    // Don't submit if there are errors
    if (Object.keys(validationErrors).length > 0) {
      return
    }
 
    try {
      setSubmitting(true)
      await onSubmit(values)
    } finally {
      setSubmitting(false)
    }
  }
 
  const reset = () => {
    setValues(initialValues)
    setErrors({})
    setTouched({})
  }
 
  return {
    values,
    errors,
    touched,
    submitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
  }
}
 
// Usage
function CreateProjectForm() {
  const form = useForm({
    initialValues: {
      title: '',
      description: '',
      startDate: '',
    },
    validate: (values) => {
      const errors: any = {}
      if (!values.title) errors.title = 'Title is required'
      if (values.title.length < 3) errors.title = 'Title must be at least 3 characters'
      if (!values.startDate) errors.startDate = 'Start date is required'
      return errors
    },
    onSubmit: async (values) => {
      await projectAPI.create(values)
      toast.success('Project created!')
    },
  })
 
  return (
    <form onSubmit={form.handleSubmit}>
      <div>
        <input
          value={form.values.title}
          onChange={(e) => form.handleChange('title', e.target.value)}
          onBlur={() => form.handleBlur('title')}
        />
        {form.touched.title && form.errors.title && (
          <span className="error">{form.errors.title}</span>
        )}
      </div>
      <button type="submit" disabled={form.submitting}>
        {form.submitting ? 'Creating...' : 'Create Project'}
      </button>
    </form>
  )
}

Performance Optimization in Large Applications

Performance becomes critical as your application grows. Here are the strategies that matter most.

1. Code Splitting with React.lazy

Don't load code users don't need:

import { lazy, Suspense } from 'react'
 
// Lazy load heavy components
const ProjectGanttChart = lazy(() => import('./components/ProjectGanttChart'))
const ReportingDashboard = lazy(() => import('./components/ReportingDashboard'))
const AdminPanel = lazy(() => import('./components/AdminPanel'))
 
function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route
          path="/projects/:id/gantt"
          element={
            <Suspense fallback={<ChartSkeleton />}>
              <ProjectGanttChart />
            </Suspense>
          }
        />
        <Route
          path="/reports"
          element={
            <Suspense fallback={<DashboardSkeleton />}>
              <ReportingDashboard />
            </Suspense>
          }
        />
      </Routes>
    </Router>
  )
}

2. Memoization: React.memo, useMemo, useCallback

Prevent unnecessary re-renders:

// Memoize expensive computations
function ProjectAnalytics({ projects }: { projects: Project[] }) {
  const statistics = useMemo(() => {
    // Expensive calculation
    return calculateProjectStatistics(projects)
  }, [projects]) // Only recalculate when projects change
 
  return <StatisticsDisplay stats={statistics} />
}
 
// Memoize callback functions
function ProjectList({ projects }: { projects: Project[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null)
 
  // Without useCallback, this creates a new function on every render
  // causing ProjectCard to re-render even if the project hasn't changed
  const handleSelect = useCallback((id: string) => {
    setSelectedId(id)
    analytics.track('project_selected', { id })
  }, []) // Empty deps - function never changes
 
  return (
    <div>
      {projects.map(project => (
        <ProjectCard
          key={project.id}
          project={project}
          onSelect={handleSelect}
          isSelected={project.id === selectedId}
        />
      ))}
    </div>
  )
}
 
// Memoize components that receive stable props
const ProjectCard = memo(function ProjectCard({
  project,
  onSelect,
  isSelected
}: {
  project: Project
  onSelect: (id: string) => void
  isSelected: boolean
}) {
  return (
    <div
      className={isSelected ? 'selected' : ''}
      onClick={() => onSelect(project.id)}
    >
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  )
})

3. Virtual Lists for Large Datasets

For lists with thousands of items, use virtualization:

import { useVirtualizer } from '@tanstack/react-virtual'
 
function LargeProjectList({ projects }: { projects: Project[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
 
  const virtualizer = useVirtualizer({
    count: projects.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // Estimated height of each item
    overscan: 5, // Render 5 items above/below visible area
  })
 
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => {
          const project = projects[virtualRow.index]
          return (
            <div
              key={virtualRow.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <ProjectCard project={project} />
            </div>
          )
        })}
      </div>
    </div>
  )
}

Error Boundaries for Resilience

Errors in one part of your app shouldn't crash the entire application:

import { Component, ReactNode } from 'react'
 
interface Props {
  children: ReactNode
  fallback?: ReactNode
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}
 
interface State {
  hasError: boolean
  error: Error | null
}
 
class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false, error: null }
  }
 
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }
 
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('ErrorBoundary caught error:', error, errorInfo)
    this.props.onError?.(error, errorInfo)
 
    // Send to error tracking service
    errorTrackingService.logError(error, errorInfo)
  }
 
  render() {
    if (this.state.hasError) {
      if (this.props.fallback) {
        return this.props.fallback
      }
 
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <details>
            <summary>Error details</summary>
            <pre>{this.state.error?.message}</pre>
          </details>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      )
    }
 
    return this.props.children
  }
}
 
// Usage: wrap components that might fail
function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Dashboard />
    </ErrorBoundary>
  )
}

Testing Strategy for Scale

A robust testing strategy is non-negotiable for large applications.

Unit Tests for Hooks and Utilities

import { renderHook, waitFor } from '@testing-library/react'
import { useQuery } from './useQuery'
 
describe('useQuery', () => {
  it('fetches data successfully', async () => {
    const fetcher = jest.fn().mockResolvedValue({ id: 1, name: 'Test' })
 
    const { result } = renderHook(() =>
      useQuery('test', fetcher)
    )
 
    expect(result.current.loading).toBe(true)
 
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })
 
    expect(result.current.data).toEqual({ id: 1, name: 'Test' })
    expect(result.current.error).toBeNull()
  })
 
  it('handles errors', async () => {
    const error = new Error('Failed to fetch')
    const fetcher = jest.fn().mockRejectedValue(error)
 
    const { result } = renderHook(() =>
      useQuery('test', fetcher)
    )
 
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })
 
    expect(result.current.error).toEqual(error)
    expect(result.current.data).toBeNull()
  })
})

Integration Tests for Features

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { CreateProjectForm } from './CreateProjectForm'
import * as projectAPI from '@/api/projects'
 
jest.mock('@/api/projects')
 
describe('CreateProjectForm', () => {
  it('creates project with valid data', async () => {
    const mockCreate = jest.spyOn(projectAPI, 'create').mockResolvedValue({
      id: '123',
      title: 'New Project',
      description: 'Test project',
    })
 
    render(<CreateProjectForm />)
 
    fireEvent.change(screen.getByLabelText(/title/i), {
      target: { value: 'New Project' }
    })
 
    fireEvent.change(screen.getByLabelText(/description/i), {
      target: { value: 'Test project' }
    })
 
    fireEvent.click(screen.getByRole('button', { name: /create/i }))
 
    await waitFor(() => {
      expect(mockCreate).toHaveBeenCalledWith({
        title: 'New Project',
        description: 'Test project',
      })
    })
 
    expect(screen.getByText(/project created/i)).toBeInTheDocument()
  })
})

Folder Structure for Large Applications

Here's the structure I use for enterprise applications:

src/
  app/                    # Next.js app directory or routing
    (marketing)/
    (app)/
    api/
  components/
    common/               # Shared UI components
      Button/
        Button.tsx
        Button.test.tsx
        Button.stories.tsx
      Card/
      Modal/
    features/             # Feature-specific components
      projects/
      timesheet/
      reporting/
  hooks/                  # Custom hooks
    useAuth.ts
    useQuery.ts
    useForm.ts
  lib/                    # Utilities and configurations
    api/                  # API client
    utils/                # Helper functions
    constants/
  stores/                 # State management
    project-store.ts
    user-store.ts
  types/                  # TypeScript types
    models/
    api/
    global.d.ts
  styles/                 # Global styles

Conclusion

Building scalable React applications is about making the right architectural decisions early and maintaining discipline as the codebase grows. The patterns I've shared here—from component composition to state management to performance optimization—have proven themselves across multiple enterprise applications with hundreds of thousands of lines of code.

Remember:

  • Start with simple patterns and add complexity only when needed
  • Keep components focused with single responsibilities
  • Choose state management based on actual needs, not hype
  • Optimize performance proactively, not reactively
  • Write tests for critical functionality
  • Organize code logically from day one

After building applications that have grown from 10K to millions of lines of code, I can confidently say that following these patterns will set you up for success as your application scales.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.