Most React TypeScript projects start clean and degrade fast. After working on 40+ production React applications, we've identified the patterns that separate codebases that age well from those that become unmaintainable within 18 months.
How should you structure a large React project?
Feature-based folder structure beats layer-based. Group by domain (auth, payments, dashboard) not by type (components, hooks, utils). Each feature owns its components, hooks, types, and services. When you need to delete or refactor a feature, everything is co-located.
src/ src/
components/ features/
CheckoutForm.tsx checkout/
OrderSummary.tsx components/CheckoutForm.tsx
UserAvatar.tsx components/OrderSummary.tsx
hooks/ hooks/useCheckout.ts
useCheckout.ts api/checkout.ts
useProfile.ts types.ts
api/ index.ts <- the public surface
checkout.ts profile/
profile.ts ...
types/ shared/ <- genuinely cross-feature only
index.ts ui/ hooks/ lib/
# Left: deleting checkout means hunting through four folders and
# hoping nothing else imported those files.
# Right: delete one directory. The index.ts barrel is what other
# features may import — anything else is private by convention.What TypeScript rules keep a large codebase maintainable?
- Strict mode from day 1. No `any` escape hatches.
- Define domain types in a central `types/` folder, never in component files.
- Use Zod for runtime validation at API boundaries — parse, don't trust.
- Discriminated unions for state machines (loading/error/success/idle).
// Four booleans allow 16 combinations, most of them nonsense —
// isLoading && error && data is a state your UI must still handle.
type Bad<T> = { isIdle: boolean; isLoading: boolean; error?: Error; data?: T };
// One union allows exactly four, and TypeScript narrows each branch:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };
function OrderView({ state }: { state: RequestState<Order> }) {
switch (state.status) {
case "idle":
case "loading": return <Spinner />;
case "error": return <ErrorPanel error={state.error} />; // .data doesn't exist here
case "success": return <OrderDetail order={state.data} />; // .data is Order, not Order | undefined
}
}
// Add a fifth state later and every switch that forgot it fails
// to compile — which is the whole point.Do you actually need Redux or Zustand?
Don't reach for Redux or Zustand immediately. Server state (TanStack Query) + URL state (React Router) handles 80% of real-world needs. Local state for UI, context for theme/auth, and a store (Zustand) only for genuinely global client state.
Performance
- Code splitting at route level — lazy load every page.
- Virtualize lists over 100 items (TanStack Virtual).
- Memoize expensive computations (useMemo), not renders (React.memo) — get the dependency arrays right.
- Bundle analyze quarterly — find unused imports before they accumulate.
