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.
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.tsxAfter 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 logicEvery 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 typesThis 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:
// 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):
// ā
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:
// 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:
// 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:
// 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// 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
// 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
// ā 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// ā
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
// ā 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
// ā 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 ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā// 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:
// ā 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
// 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
// ā
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
// 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
// .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
// 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:
# 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 dependencyADRs 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 workSummary
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 + ADRsThe 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.