Scaling a React app is less about technical tricks and more about enforcing consistent patterns before complexity hits. The decisions you make at 5K lines of code will either serve you or haunt you at 50K.
Feature-First Folder Structure
Flat component folders collapse under real-world feature growth. Instead, co-locate everything a feature needs — components, hooks, types, API calls — in a dedicated directory.
src/
features/
auth/
components/ # LoginForm, SignupForm
hooks/ # useAuth, useSession
api/ # auth.api.ts
types.ts
dashboard/
...
shared/
components/ # Button, Input, Modal
hooks/ # useDebounce, useLocalStorage
utils/Tip: A good heuristic: if a component is used by more than one feature, move it to
shared/. If it's only used within one feature, keep it co-located.
Custom Hooks for Logic
Components should be thin. Extract all non-rendering logic into custom hooks. This makes it easy to test, reuse, and replace implementation details without touching the UI.
// features/auth/hooks/useAuth.ts
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
getSession().then(setUser).finally(() => setLoading(false))
}, [])
const logout = useCallback(async () => {
await signOut()
setUser(null)
}, [])
return { user, loading, logout }
}State Management
Not every state needs a global store. Follow this decision hierarchy:
- UI state (open/closed, selected tab) →
useStatein the component - Shared local state (form values across siblings) → lift to parent or
useContext - Server state (API data) → React Query or SWR
- Complex global state → Zustand or Jotai (avoid Redux for most apps)
Performance: Memoization Done Right
Over-memoization is a real problem. Wrapping everything in useMemo and useCallback adds complexity without benefit if the computation is trivial or the dependency array is unstable.
// ✅ Worthwhile — expensive computation, stable deps
const sortedItems = useMemo(
() => items.slice().sort((a, b) => b.score - a.score),
[items]
)
// ❌ Wasteful — object literal recreates every render anyway
const config = useMemo(() => ({ color: "red" }), [])
const config = { color: "red" } // simpler and equivalentNote: Profile first with React DevTools Profiler before optimizing. Real bottlenecks are rarely where you expect them.
Absolute Imports
Relative import chains (../../../../components) are a refactoring nightmare. Configure path aliases so imports read like intent, not filesystem navigation:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
// Before
import { Button } from "../../../shared/components/Button"
// After
import { Button } from "@/shared/components/Button"Summary
- Use feature-first folder structure from day one
- Keep components thin — move logic into custom hooks
- Match state to the right tool (local, context, server, global)
- Memoize only what profiling reveals as a bottleneck
- Configure path aliases to keep imports readable