DevelopmentReact

Building Scalable React Applications: Best Practices

Gyrate Digital

Gyrate Digital

Author

8th Oct 2025
12 min read
Blog Post
Building Scalable React Applications: Best Practices

Summary

Learn how to structure your React applications for maximum scalability and maintainability.

Building scalable React applications requires careful planning and adherence to best practices. As your application grows, maintaining code quality and performance becomes increasingly challenging. This comprehensive guide covers proven patterns and practices for building React applications that scale.

Foundation: Project Structure That Scales

Before diving into code, establish a solid architectural foundation that supports growth.

Feature-Based Architecture

src/
├── components/
│   ├── common/           # Shared components
│   ├── ui/              # Design system components
│   └── layout/          # Layout components
├── features/            # Feature-based organization
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/
│   │   └── types/
│   ├── dashboard/
│   └── products/
├── hooks/               # Custom hooks
├── services/            # API services
├── store/               # State management
├── types/               # TypeScript definitions
├── utils/               # Utility functions
└── constants/           # App constants

Component Architecture Patterns

1. Container/Presentational Pattern

Separate business logic from presentation logic for better testability and reusability.

// Container Component (Business Logic)
import { useState, useEffect } from 'react';
import { UserList } from './UserList';

export const UserListContainer = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        const response = await fetch('/api/users');
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  const handleUserDelete = async (userId) => {
    try {
      await fetch(`/api/users/${userId}`, { method: 'DELETE' });
      setUsers(users.filter(user => user.id !== userId));
    } catch (err) {
      setError('Failed to delete user');
    }
  };

  return (
    <UserList
      users={users}
      loading={loading}
      error={error}
      onUserDelete={handleUserDelete}
    />
  );
};

// Presentational Component (UI Only)
interface UserListProps {
  users: User[];
  loading: boolean;
  error: string | null;
  onUserDelete: (userId: string) => void;
}

export const UserList = ({ users, loading, error, onUserDelete }: UserListProps) => {
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => onUserDelete(user.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
};

2. Compound Components Pattern

Create flexible, reusable component APIs that work together seamlessly.

import React, { createContext, useContext, useState } from 'react';

// Context for compound component communication
const TabsContext = createContext();

const Tabs = ({ children, defaultActive = 0 }) => {
  const [activeIndex, setActiveIndex] = useState(defaultActive);

  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
};

const TabList = ({ children }) => (
  <div className="tab-list">{children}</div>
);

const Tab = ({ index, children }) => {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);

  return (
    <button
      className={`tab ${activeIndex === index ? 'active' : ''}`}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
};

const TabPanels = ({ children }) => (
  <div className="tab-panels">{children}</div>
);

const TabPanel = ({ index, children }) => {
  const { activeIndex } = useContext(TabsContext);

  return activeIndex === index ? (
    <div className="tab-panel">{children}</div>
  ) : null;
};

// Usage
<Tabs>
  <TabList>
    <Tab index={0}>Profile</Tab>
    <Tab index={1}>Settings</Tab>
    <Tab index={2}>Notifications</Tab>
  </TabList>
  <TabPanels>
    <TabPanel index={0}>Profile Content</TabPanel>
    <TabPanel index={1}>Settings Content</TabPanel>
    <TabPanel index={2}>Notifications Content</TabPanel>
  </TabPanels>
</Tabs>

State Management Strategies

Progressive State Management

📱 Local State

When to use: Component-specific state

Tools: useState, useReducer

Best for: Form inputs, UI state, simple interactions

🔄 Global State

When to use: App-wide state, complex interactions

Tools: Context API, Zustand, Redux Toolkit

Best for: User auth, app settings, complex workflows

☁️ Server State

When to use: API data, real-time updates

Tools: React Query, SWR, Apollo Client

Best for: API responses, caching, synchronization

Zustand Implementation Example

// store/auth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  login: (user: User) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      login: (user) => set({ user, isAuthenticated: true }),
      logout: () => set({ user: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
    }
  )
);

// store/products.ts
import { create } from 'zustand';

interface ProductState {
  products: Product[];
  loading: boolean;
  error: string | null;
  fetchProducts: () => Promise<void>;
  addProduct: (product: Product) => void;
}

export const useProductStore = create<ProductState>((set, get) => ({
  products: [],
  loading: false,
  error: null,

  fetchProducts: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('/api/products');
      const products = await response.json();
      set({ products, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },

  addProduct: (product) =>
    set((state) => ({
      products: [...state.products, product],
    })),
}));

Performance Optimization Techniques

1. Code Splitting & Lazy Loading

// Route-based code splitting
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
const Analytics = lazy(() => import('./pages/Analytics'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/products" element={<Products />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  </Router>
);

// Component-based code splitting
const HeavyComponent = lazy(() => import('./components/HeavyComponent'));

const MyComponent = () => {
  const [showHeavy, setShowHeavy] = useState(false);

  return (
    <div>
      <button onClick={() => setShowHeavy(true)}>
        Load Heavy Component
      </button>

      {showHeavy && (
        <Suspense fallback={<div>Loading heavy component...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
};

2. Memoization Strategies

// Component memoization
const ProductCard = React.memo(({ product, onAddToCart }) => {
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product)}>Add to Cart</button>
    </div>
  );
});

// Callback memoization
const ProductList = ({ products, addToCart }) => {
  const handleAddToCart = useCallback((product) => {
    addToCart(product);
  }, [addToCart]);

  return (
    <div>
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
};

// Value memoization
const UserProfile = ({ userId }) => {
  const userData = useMemo(() => {
    return expensiveUserLookup(userId);
  }, [userId]);

  const userStats = useMemo(() => {
    return calculateUserStats(userData);
  }, [userData]);

  return (
    <div>
      <h2>{userData.name}</h2>
      <p>Posts: {userStats.postCount}</p>
      <p>Likes: {userStats.likeCount}</p>
    </div>
  );
};

3. Virtual Scrolling for Large Lists

import { FixedSizeList as List } from 'react-window';

const VirtualizedProductList = ({ products }) => {
  const Row = ({ index, style }) => {
    const product = products[index];

    return (
      <div style={style} className="product-row">
        <img src={product.image} alt={product.name} />
        <div>
          <h4>{product.name}</h4>
          <p>${product.price}</p>
        </div>
        <button>Add to Cart</button>
      </div>
    );
  };

  return (
    <List
      height={400}
      itemCount={products.length}
      itemSize={100}
      width="100%"
    >
      {Row}
    </List>
  );
};

Custom Hooks for Reusability

Create custom hooks to extract and reuse complex logic across components.

// hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (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) {
      // Handle error silently
    }
  };

  return [storedValue, setValue] as const;
}

// hooks/useApi.ts
import { useState, useCallback } from 'react';

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

  const execute = useCallback(async (options?: RequestInit) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const result = await response.json();
      setData(result);
      return result;
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'An error occurred';
      setError(errorMessage);
      throw err;
    } finally {
      setLoading(false);
    }
  }, [url]);

  return { data, loading, error, execute };
}

// Usage
const ProductManager = () => {
  const [products, setProducts] = useLocalStorage('products', []);
  const { data, loading, error, execute } = useApi('/api/products');

  const addProduct = async (product) => {
    try {
      await execute({
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
      });
      setProducts([...products, product]);
    } catch (err) {
      // Handle error silently
    }
  };

  return (
    <div>
      {/* Component JSX */}
    </div>
  );
};

Testing Strategies for Scalable Apps

Component Testing with React Testing Library

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProductCard } from './ProductCard';

const mockProduct = {
  id: '1',
  name: 'Test Product',
  price: 29.99,
  image: '/test-image.jpg'
};

const mockAddToCart = jest.fn();

describe('ProductCard', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('renders product information correctly', () => {
    render(<ProductCard product={mockProduct} onAddToCart={mockAddToCart} />);

    expect(screen.getByText('Test Product')).toBeInTheDocument();
    expect(screen.getByText('$29.99')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /add to cart/i })).toBeInTheDocument();
  });

  it('calls onAddToCart when button is clicked', () => {
    render(<ProductCard product={mockProduct} onAddToCart={mockAddToCart} />);

    const addButton = screen.getByRole('button', { name: /add to cart/i });
    fireEvent.click(addButton);

    expect(mockAddToCart).toHaveBeenCalledWith(mockProduct);
  });

  it('displays product image with correct alt text', () => {
    render(<ProductCard product={mockProduct} onAddToCart={mockAddToCart} />);

    const image = screen.getByAltText('Test Product');
    expect(image).toBeInTheDocument();
    expect(image).toHaveAttribute('src', '/test-image.jpg');
  });
});

Custom Hook Testing

import { renderHook, act, waitFor } from '@testing-library/react';
import { useApi } from './useApi';

describe('useApi', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('should handle successful API call', async () => {
    const mockData = { id: 1, name: 'Test' };
    (global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockData),
    });

    const { result } = renderHook(() => useApi('/api/test'));

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBe(null);

    act(() => {
      result.current.execute();
    });

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.data).toEqual(mockData);
      expect(result.current.error).toBe(null);
    });
  });

  it('should handle API error', async () => {
    const errorMessage = 'Network error';
    (global.fetch as jest.Mock).mockRejectedValueOnce(new Error(errorMessage));

    const { result } = renderHook(() => useApi('/api/test'));

    act(() => {
      result.current.execute();
    });

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.data).toBe(null);
      expect(result.current.error).toBe(errorMessage);
    });
  });
});

Monitoring & Maintenance

Error Boundaries for Production Stability

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null, errorInfo: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    this.setState({
      error,
      errorInfo
    });

    // Log error to monitoring service
    logErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong.</h2>
          <details style={{ whiteSpace: 'pre-wrap' }}>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo.componentStack}
          </details>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Usage
const App = () => (
  <ErrorBoundary>
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<Products />} />
      </Routes>
    </Router>
  <ErrorBoundary>
);

Scaling Checklist

🏗️ Architecture & Structure

  • ✅ Feature-based folder structure implemented
  • ✅ Component composition patterns established
  • ✅ Progressive state management strategy chosen
  • ✅ TypeScript interfaces defined for all data structures

⚡ Performance & Optimization

  • ✅ Code splitting implemented for routes
  • ✅ Components memoized where appropriate
  • ✅ Bundle size monitored and optimized
  • ✅ Virtual scrolling for large lists

🧪 Quality & Testing

  • ✅ Component tests written for critical UI
  • ✅ Custom hooks tested in isolation
  • ✅ Error boundaries implemented
  • ✅ Performance budgets established

🔄 Maintenance & Monitoring

  • ✅ Error tracking and logging set up
  • ✅ Performance monitoring implemented
  • ✅ Code documentation maintained
  • ✅ Regular dependency updates scheduled

Final Thoughts

Building scalable React applications is an ongoing process that requires attention to architecture, performance, and maintainability from day one. By following these patterns and best practices, you'll create applications that can grow with your business needs while maintaining code quality and developer productivity.

"Scalability isn't just about handling more users—it's about maintaining code quality, developer velocity, and user experience as your application grows. Invest in good architecture early, and your future self will thank you."

— Sarah Chen, Senior React Architect
Gyrate Digital

About Gyrate Digital

Full-service digital agency specializing in design, development, and marketing that helps brands grow online.

logo

Contact

United Kingdom Office

33 Copgrove Road, Leeds,
West Yorkshire LS8 2SP, United Kingdom

+44 7943 939124

Bahrain Office

Office 210, Building 1691,
Road 432, Salmabad 704, Bahrain

+973 3467 9176

Subscribe

Subscribe to our newsletter for the latest updates.

© 2025 Gyrate Digital