Back to blog
Frontend Engineeringadvanced

Scaling React for Enterprise Teams: The Complete Playbook

Scaling React isn't about adding more components. It's about building a codebase 50 developers can work in without chaos. This covers project structure, component architecture, state management, performance, testing, and team practices — with real patterns used in production.

LearnixoApril 19, 202615 min read
ReactTypeScriptArchitectureEnterprisePerformanceState ManagementTesting
Share:š•

Scaling React for enterprise teams is not a component problem. It's a system problem. The challenge isn't writing more React — it's designing a codebase that 10, 30, or 100 developers can work in simultaneously without stepping on each other, duplicating work, or shipping inconsistent UIs.

This is the complete playbook.


The Enterprise React Problem

A small React app looks like this:

src/
ā”œā”€ā”€ components/
│   ā”œā”€ā”€ Button.tsx
│   ā”œā”€ā”€ Modal.tsx
│   └── UserCard.tsx
ā”œā”€ā”€ pages/
│   ā”œā”€ā”€ Home.tsx
│   └── Dashboard.tsx
└── App.tsx

After 18 months and 12 developers, the same app looks like this:

src/
ā”œā”€ā”€ components/         ← 340 files, 30% duplicates
ā”œā”€ā”€ containers/         ← nobody remembers what this means
ā”œā”€ā”€ pages/              ← also has components in it
ā”œā”€ā”€ modules/            ← someone's refactor, abandoned
ā”œā”€ā”€ features/           ← another refactor, also abandoned
ā”œā”€ā”€ hooks/              ← useUserData exists 4 times
ā”œā”€ā”€ utils/              ← 200 functions, 0 documentation
ā”œā”€ā”€ helpers/            ← like utils but different (nobody knows how)
└── services/           ← api calls mixed with business logic

Every developer has their own mental model. Nobody agrees on where new code goes. The codebase grows — but becomes harder to change, not easier.

Enterprise scaling means designing the structure before it becomes chaos, not after.


1. Project Structure: Feature-First, Not Layer-First

The Problem with Layer-First Structure

Most tutorials teach layer-first organization:

src/
ā”œā”€ā”€ components/    ← all components from all features
ā”œā”€ā”€ hooks/         ← all hooks from all features
ā”œā”€ā”€ services/      ← all API calls
ā”œā”€ā”€ store/         ← all state
└── types/         ← all types

This breaks at scale because:

  • Changing a feature requires touching 5+ folders
  • No clear ownership — any developer touches any folder
  • Finding code requires knowing the layer, not the feature

Feature-First: Vertical Slices

Organize by what the code does, not how it's implemented:

src/
│
ā”œā”€ā”€ features/                     ← domain features (vertical slices)
│   ā”œā”€ā”€ auth/
│   │   ā”œā”€ā”€ components/           ← auth-specific components
│   │   │   ā”œā”€ā”€ LoginForm.tsx
│   │   │   └── OAuthButton.tsx
│   │   ā”œā”€ā”€ hooks/                ← auth-specific hooks
│   │   │   └── useAuth.ts
│   │   ā”œā”€ā”€ api/                  ← auth API calls
│   │   │   └── authApi.ts
│   │   ā”œā”€ā”€ store/                ← auth state slice
│   │   │   └── authSlice.ts
│   │   ā”œā”€ā”€ types.ts              ← auth-specific types
│   │   └── index.ts              ← public API of the feature
│   │
│   ā”œā”€ā”€ orders/
│   │   ā”œā”€ā”€ components/
│   │   ā”œā”€ā”€ hooks/
│   │   ā”œā”€ā”€ api/
│   │   ā”œā”€ā”€ store/
│   │   ā”œā”€ā”€ types.ts
│   │   └── index.ts
│   │
│   └── dashboard/
│       └── ...
│
ā”œā”€ā”€ shared/                       ← truly shared, stable code
│   ā”œā”€ā”€ components/               ← Button, Modal, Table, Input
│   ā”œā”€ā”€ hooks/                    ← useDebounce, usePagination
│   ā”œā”€ā”€ utils/                    ← formatDate, parseError
│   └── types/                    ← User, ApiResponse
│
ā”œā”€ā”€ core/                         ← app-wide infrastructure
│   ā”œā”€ā”€ router/
│   ā”œā”€ā”€ i18n/
│   ā”œā”€ā”€ theme/
│   └── api/                      ← base axios/fetch config
│
└── app/                          ← entry point, providers, shell
    ā”œā”€ā”€ App.tsx
    ā”œā”€ā”€ Providers.tsx
    └── layouts/

The Barrel Export Rule

Each feature exposes a public API via index.ts. Nothing outside the feature imports from inside it directly:

TYPESCRIPT
// features/orders/index.ts — the public API
export { OrderList } from './components/OrderList';
export { OrderDetail } from './components/OrderDetail';
export { useOrders } from './hooks/useOrders';
export type { Order, OrderStatus } from './types';

// āœ… Outside the feature — import from the public API
import { OrderList, useOrders } from '@/features/orders';

// āŒ Never do this — bypasses encapsulation
import { OrderList } from '@/features/orders/components/OrderList';

This rule means you can reorganize internals freely — nothing outside breaks.


2. Component Architecture: Three Layers

Enterprise UI components exist at three levels. Mixing them is where complexity comes from.

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│                    Page / Route                          │
│   Knows about routes, layout, data orchestration        │
│   Uses: features, layouts, shared components            │
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│                  Feature Component                       │
│   Knows about business domain, state, side effects      │
│   Uses: shared components, hooks, store                 │
ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤
│                   UI Component                           │
│   Pure, stateless, no API calls, no store               │
│   Props in → JSX out. Fully reusable.                   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

UI Components (Design System Layer)

No business logic. No API calls. No state other than UI state (open/closed, hover):

TSX
// āœ… Pure UI — works the same everywhere
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}

export function Button({ variant, size, isLoading, onClick, children }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }))}
      disabled={isLoading}
      onClick={onClick}
    >
      {isLoading ? <Spinner size="sm" /> : children}
    </button>
  );
}

Feature Components (Business Logic Layer)

Know about the domain. Connect to state. Make API calls via hooks:

TSX
// Feature component — orchestrates business logic
export function OrderList() {
  const { orders, isLoading, error } = useOrders();
  const { mutate: cancelOrder } = useCancelOrder();

  if (isLoading) return <OrderListSkeleton />;
  if (error) return <ErrorState message={error.message} />;

  return (
    <div>
      {orders.map(order => (
        <OrderCard
          key={order.id}
          order={order}
          onCancel={() => cancelOrder(order.id)}
        />
      ))}
    </div>
  );
}

Page Components (Route Layer)

Thin. Only compose features and handle layout:

TSX
// Page — knows about routing and layout, not business logic
export default function OrdersPage() {
  return (
    <DashboardLayout>
      <PageHeader title="Orders" actions={<CreateOrderButton />} />
      <OrderFilters />
      <OrderList />
    </DashboardLayout>
  );
}

3. State Management: Right Tool, Right Job

Not all state is the same. Using the wrong tool for each type is the biggest source of complexity:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│                    State Classification                        │
│                                                               │
│  Server State          UI State            App State          │
│  ─────────────         ────────────        ──────────────     │
│  API responses         Modal open/closed   Auth token         │
│  User profiles         Tab selection       User preferences   │
│  Order lists           Form input          Theme              │
│  Search results        Tooltip hover       Permissions        │
│                                                               │
│  → React Query         → useState          → Zustand / Redux  │
│  → SWR                 → useReducer        → Context (small)  │
│                        → Zustand (if       → Jotai (atomic)   │
│                          shared UI state)                     │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Server State: React Query

Most "global state" is just cached server data. React Query handles it better than Redux ever did:

TSX
// Without React Query — 50 lines of boilerplate per endpoint
const [orders, setOrders] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setIsLoading(true);
  fetchOrders()
    .then(setOrders)
    .catch(setError)
    .finally(() => setIsLoading(false));
}, []);

// With React Query — everything included
const { data: orders, isLoading, error } = useQuery({
  queryKey: ['orders', { status: 'pending' }],
  queryFn: () => orderApi.getOrders({ status: 'pending' }),
  staleTime: 1000 * 60 * 5, // 5 minutes
});

React Query gives you: caching, deduplication, background refetch, pagination, optimistic updates, and error/loading states — for free.

Client State: Zustand for Shared, useState for Local

Decision tree for client state:

Is the state used in only one component?
    → useState

Is the state UI-only (open/closed, selected tab)?
    → useState in the nearest parent

Is the state shared across distant components?
    → Zustand slice

Is the state global app-level (auth, theme, permissions)?
    → Zustand store or Context
TYPESCRIPT
// Zustand slice — simple, no boilerplate
interface OrderFilterStore {
  status: OrderStatus | 'all';
  dateRange: DateRange | null;
  setStatus: (status: OrderStatus | 'all') => void;
  setDateRange: (range: DateRange | null) => void;
  reset: () => void;
}

export const useOrderFilters = create<OrderFilterStore>((set) => ({
  status: 'all',
  dateRange: null,
  setStatus: (status) => set({ status }),
  setDateRange: (dateRange) => set({ dateRange }),
  reset: () => set({ status: 'all', dateRange: null }),
}));

When to Use Redux Toolkit

Redux is the right choice when:

  • Complex state transitions with many actors
  • Time-travel debugging is needed
  • Very large team with strict action-based audit trail
TYPESCRIPT
// Redux Toolkit slice — structured, predictable
const ordersSlice = createSlice({
  name: 'orders',
  initialState: { items: [], selectedId: null } as OrdersState,
  reducers: {
    orderSelected: (state, action: PayloadAction<string>) => {
      state.selectedId = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder.addMatcher(
      ordersApi.endpoints.getOrders.matchFulfilled,
      (state, { payload }) => {
        state.items = payload;
      }
    );
  },
});

4. Performance: Built In, Not Bolted On

Performance patterns that must be default from day one:

Code Splitting at the Route Level

TSX
// āŒ Everything loads upfront — slow initial bundle
import { OrdersPage } from './pages/OrdersPage';
import { ReportsPage } from './pages/ReportsPage';
import { AdminPage } from './pages/AdminPage';

// āœ… Each route loads its own bundle only when visited
const OrdersPage = lazy(() => import('./pages/OrdersPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));

function AppRouter() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/orders" element={<OrdersPage />} />
        <Route path="/reports" element={<ReportsPage />} />
        <Route path="/admin" element={<AdminPage />} />
      </Routes>
    </Suspense>
  );
}

Memoization: When It Helps and When It Doesn't

The memoization decision tree:

Does this component re-render frequently?
    No → Don't memoize (adds overhead with no benefit)
    Yes → Does it receive the same props when re-rendering?
              Yes → memo() helps
              No → memo() won't help (props change = re-render anyway)

Is this a computed value?
    Small/fast computation → Don't useMemo (cost > benefit)
    Expensive computation  → useMemo helps
    Referential equality needed (array/object as dep) → useMemo

Is this a callback passed to memoized children?
    Yes → useCallback
    No  → inline function is fine
TSX
// āœ… Memoize components that receive stable props but re-render due to parent
const OrderCard = memo(function OrderCard({ order, onCancel }: OrderCardProps) {
  return (
    <div>
      <h3>{order.id}</h3>
      <button onClick={onCancel}>Cancel</button>
    </div>
  );
});

// āœ… Memoize the callback so OrderCard doesn't re-render needlessly
function OrderList({ orders }: { orders: Order[] }) {
  const handleCancel = useCallback((id: string) => {
    cancelOrder(id);
  }, []); // stable reference

  return orders.map(order => (
    <OrderCard key={order.id} order={order} onCancel={() => handleCancel(order.id)} />
  ));
}

Virtualization for Long Lists

TSX
// āŒ Renders 10,000 DOM nodes
function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// āœ… Renders only what's visible (~15–20 items)
import { useVirtualizer } from '@tanstack/react-virtual';

function ProductList({ products }: { products: Product[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: products.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(item => (
          <div
            key={item.key}
            style={{ position: 'absolute', top: item.start, width: '100%' }}
          >
            <ProductCard product={products[item.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

5. API Layer: Typed, Centralized, Testable

The Anti-Pattern: API Calls Scattered Everywhere

TSX
// āŒ Fetch in component — untestable, uncacheable, duplicated
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

The Pattern: Typed API Client + Custom Hooks

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Component                                      │
│   useOrders() ◄─────────────────────────────┐   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”˜
                                              │
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”
│   Custom Hook (useOrders)                   │   │
│   React Query ─── orderApi.getOrders() ā”€ā”€ā”€ā”€ā”€ā”˜   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                              │
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   API Client (orderApi)                          │
│   Typed, thin wrapper around HTTP               │
│   Handles: auth headers, base URL, errors       │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                              │
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   HTTP Client (axios instance / fetch)           │
│   Interceptors: token refresh, error parsing    │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
TYPESCRIPT
// core/api/client.ts — base HTTP client
const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL });

apiClient.interceptors.request.use(config => {
  const token = getAuthToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

apiClient.interceptors.response.use(
  res => res,
  async error => {
    if (error.response?.status === 401) await refreshToken();
    return Promise.reject(parseApiError(error));
  }
);

// features/orders/api/ordersApi.ts — typed API layer
export const ordersApi = {
  getOrders: (params: GetOrdersParams): Promise<Order[]> =>
    apiClient.get('/orders', { params }).then(r => r.data),

  getOrder: (id: string): Promise<Order> =>
    apiClient.get(`/orders/${id}`).then(r => r.data),

  cancelOrder: (id: string): Promise<void> =>
    apiClient.post(`/orders/${id}/cancel`),
};

// features/orders/hooks/useOrders.ts — hook wires React Query + API
export function useOrders(params: GetOrdersParams = {}) {
  return useQuery({
    queryKey: ['orders', params],
    queryFn: () => ordersApi.getOrders(params),
  });
}

export function useCancelOrder() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ordersApi.cancelOrder,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['orders'] }),
  });
}

6. TypeScript: Make Wrong States Unrepresentable

TypeScript in enterprise React isn't about adding : string everywhere. It's about designing types that make invalid states impossible:

TYPESCRIPT
// āŒ Allows impossible states: isLoading=true, data=defined, error=defined
interface State {
  data: Order[] | null;
  isLoading: boolean;
  error: Error | null;
}

// āœ… Discriminated union — exactly one state is active at any time
type OrdersState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Order[] }
  | { status: 'error'; error: Error };

// Usage — TypeScript forces you to handle every case
function renderOrders(state: OrdersState) {
  switch (state.status) {
    case 'idle':    return <EmptyState />;
    case 'loading': return <Skeleton />;
    case 'success': return <OrderList orders={state.data} />;
    case 'error':   return <ErrorBanner error={state.error} />;
  }
}

Strict TypeScript Config for Enterprise

JSON
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true
  }
}

These options catch entire categories of runtime bugs at compile time.


7. Testing Strategy: Test Behavior, Not Implementation

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│                  Testing Pyramid                          │
│                                                          │
│                    ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”                              │
│                    │  E2E │  ← 10% — happy path flows    │
│                    │      │     (Playwright/Cypress)     │
│                  ā”Œā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”                            │
│                  │Integration│ ← 30% — feature workflows │
│                  │   Tests   │    (React Testing Library) │
│              ā”Œā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”                       │
│              │    Unit Tests     │ ← 60% — hooks, utils  │
│              │                   │    (Vitest)           │
│              ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜                       │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Integration Tests: Test What Users Do

TSX
// āœ… Tests the feature from the user's perspective
import { render, screen, userEvent } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

test('user can cancel a pending order', async () => {
  server.use(
    http.get('/api/orders', () => HttpResponse.json([mockPendingOrder])),
    http.post('/api/orders/:id/cancel', () => HttpResponse.json({ ok: true }))
  );

  render(<OrdersPage />, { wrapper: AppProviders });

  await screen.findByText('Order #1234'); // wait for data
  await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
  await userEvent.click(screen.getByRole('button', { name: /confirm/i }));

  expect(screen.getByText('Order cancelled')).toBeInTheDocument();
});

Unit Tests: Business Logic Only

TYPESCRIPT
// Test hooks in isolation
test('useOrderFilters resets correctly', () => {
  const { result } = renderHook(() => useOrderFilters());

  act(() => result.current.setStatus('pending'));
  expect(result.current.status).toBe('pending');

  act(() => result.current.reset());
  expect(result.current.status).toBe('all');
});

8. Team Practices: The Architecture That Lives in People

All of the above is worthless if the team doesn't follow it. Structure enforced only by convention decays within months.

Enforce Structure with ESLint

JAVASCRIPT
// .eslintrc.js — import boundaries enforced automatically
rules: {
  'no-restricted-imports': ['error', {
    patterns: [
      // Force public API usage
      { group: ['@/features/*/components/*'], message: 'Import from feature index' },
      { group: ['@/features/*/hooks/*'], message: 'Import from feature index' },
    ]
  }]
}

Feature Flag Pattern for Safe Releases

TSX
// Ship incomplete features to production safely
function OrdersPage() {
  const { isEnabled } = useFeatureFlag('new-order-timeline');

  return (
    <div>
      <OrderList />
      {isEnabled('new-order-timeline') && <OrderTimeline />}
    </div>
  );
}

Architecture Decision Records (ADRs)

Document why decisions were made, not just what they are:

MARKDOWN
# ADR-012: Use React Query for server state

## Context
Multiple developers were implementing their own loading/error/caching
patterns for API calls, leading to inconsistency and bugs.

## Decision
Use React Query for all server state. Zustand for UI/client state.

## Consequences
+ Consistent loading/error handling across the app
+ Built-in caching reduces API calls
- Team needs to learn React Query patterns
- One more dependency

ADRs prevent the same debate from happening every 6 months when new developers join and question decisions.


The Enterprise React Checklist

Project Structure
  āœ… Feature-first vertical slices
  āœ… Barrel exports (public API per feature)
  āœ… Shared / core / features separation

Components
  āœ… UI components are pure and stateless
  āœ… Feature components handle business logic
  āœ… Page components are thin orchestrators

State Management
  āœ… Server state → React Query
  āœ… Shared UI state → Zustand
  āœ… Local UI state → useState
  āœ… No business logic in Redux/Zustand (just data)

Performance
  āœ… Route-level code splitting with React.lazy
  āœ… Virtualization for lists > 100 items
  āœ… Memoization only where profiling shows benefit

API Layer
  āœ… Typed API client
  āœ… Custom hooks wrap React Query
  āœ… No fetch() calls in components

TypeScript
  āœ… strict: true enabled
  āœ… Discriminated unions for async state
  āœ… No any (linting enforced)

Testing
  āœ… Integration tests with React Testing Library
  āœ… MSW for API mocking
  āœ… E2E for critical user flows

Team
  āœ… ESLint enforces import boundaries
  āœ… ADRs for major decisions
  āœ… Feature flags for incomplete work

Summary

Small team (1–5 devs)        Growing team (5–20 devs)       Enterprise (20+ devs)
──────────────────────        ────────────────────────       ─────────────────────
Pages + components            Feature-first structure        Monorepo with packages
useState + fetch              React Query + Zustand          React Query + RTK
Ad hoc patterns               Shared component library       Design system + Storybook
Light TypeScript              Strict TypeScript              Generated types from API
Basic tests                   Integration tests + MSW        Full pyramid + E2E
                                                             Feature flags + ADRs

The architecture that works for a 3-person team will break a 30-person team. The architecture that works for a 30-person team is overkill for 3.

Start with the feature-first structure and barrel exports from day one — these cost nothing and scale infinitely. Add the rest when your team size demands it.

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.