Frontend Architecture & State Management
Component architecture, state management trade-offs, rendering strategies (CSR/SSR/SSG/ISR), and the patterns senior engineers use to keep large codebases maintainable.
Frontend Architecture & State Management
Component architecture, state management trade-offs, rendering strategies (CSR/SSR/SSG/ISR), and the patterns senior engineers use to keep large codebases maintainable.
What you'll learn
- Separate UI components (dumb) from hooks (logic) from pages (orchestration)
- Server state → React Query. URL state → URLSearchParams. Local state → useState. Global UI → Zustand
- SSR for SEO + fast FCP, SSG for static, ISR for semi-dynamic, CSR for authenticated apps
- Profile before optimizing — useMemo/useCallback have overhead and are often misused
- Error Boundaries + Suspense create resilient UIs that degrade gracefully instead of crashing
- Virtualize any list with 100+ rows — react-window renders only visible items
Lesson outline
The problem with "just use React"
React is a library, not a framework — it solves the view layer. Everything else (routing, state, data fetching, caching, forms, auth) you have to solve yourself or pick a library for. A junior React developer picks libraries based on popularity. A senior developer picks based on the specific problem being solved.
Most React codebases collapse under their own weight after 6-12 months because they lack architectural decisions: where does state live? What owns data fetching? How do components communicate? How do we keep re-renders fast? Answering these questions upfront is frontend architecture.
Component architecture: thinking in layers
The most durable React applications separate concerns into layers:
UI Components (dumb/presentational): Only props, no business logic, no data fetching. Easy to test, easy to reuse, easy to swap implementations. `<Button>`, `<Card>`, `<DataTable>` — your design system lives here.
Container/Feature Components: Compose UI components, may call hooks, may dispatch actions. Owns the "widget" logic. `<OrderSummary>` — knows what to display and when, but does not know how to fetch the order.
Pages: Route-level components. Orchestrate feature components. May fetch top-level data via SSR or useQuery.
Hooks: Extract reusable stateful logic. `useOrderStatus`, `useInfiniteScroll`, `useDebounce`. A hook is a pure function that returns state and callbacks — pure gold for testability.
The "smart/dumb" component split is often wrong
Strict smart/dumb separation breaks down when data-fetching libraries (React Query, SWR) put caching at the component level. A better mental model: UI layer (renders), hook layer (logic + data), service layer (API calls). Keep each layer single-purpose.
1// ── Layer 1: Pure UI Component (no state, no fetching) ─────────────────Pure UI component: easy to test in Storybook, zero dependencies2interface OrderCardProps {3orderId: string;4status: 'pending' | 'shipped' | 'delivered';5total: number;6onCancel: (id: string) => void;7}89export function OrderCard({ orderId, status, total, onCancel }: OrderCardProps) {10return (11<div className="border rounded-lg p-4">12<span className="text-sm text-gray-500">#{orderId}</span>13<StatusBadge status={status} />14<p className="font-bold">${total.toFixed(2)}</p>15{status === 'pending' && (16<button onClick={() => onCancel(orderId)}>Cancel</button>17)}18</div>19);20}2122// ── Layer 2: Hook (data + logic) ─────────────────────────────────────────React Query handles caching, background refetch, loading states — do not reinvent this23function useOrders(userId: string) {24const { data, isLoading, error } = useQuery({25queryKey: ['orders', userId],26queryFn: () => api.getOrders(userId),27staleTime: 30_000,28});2930const cancelOrder = useMutation({31mutationFn: (orderId: string) => api.cancelOrder(orderId),32onSuccess: () => queryClient.invalidateQueries({ queryKey: ['orders', userId] }),33});3435return {36orders: data ?? [],37isLoading,38error,39cancelOrder: cancelOrder.mutate,40};41}4243// ── Layer 3: Feature Component (composes hook + UI) ───────────────────────Feature component is thin — just wires hook to UI44export function OrderList({ userId }: { userId: string }) {45const { orders, isLoading, cancelOrder } = useOrders(userId);4647if (isLoading) return <OrderListSkeleton />;4849return (50<div className="space-y-4">51{orders.map(order => (52<OrderCard53key={order.id}54{...order}55onCancel={cancelOrder}56/>57))}58</div>59);60}
State management: choosing the right tool
State has two fundamentally different categories with different solutions:
Server state (data from your API): Async, potentially stale, cached. Use TanStack Query (React Query) or SWR. They handle caching, background refetch, optimistic updates, deduplication of concurrent requests, and loading/error states. Do not put server state in Zustand or Redux.
Client state (UI state): Synchronous, local to the browser. Sub-categories:
- 🔗URL state — Current page, filters, selected tab — use URLSearchParams. Shareable, bookmarkable.
- 📦Local component state — Open/closed modal, form input values — useState/useReducer. Keep close to where it is used.
- 🌍Global UI state — Current user, theme, notification queue — Context or Zustand. Only for truly shared state.
When to reach for Zustand/Redux: Only when you have complex, global, synchronous client state that needs to be shared across many unrelated components. This is rarer than most developers think. A "user session" context and React Query covers 80% of apps.
Redux is often over-engineering
If you are using Redux to store API response data, you are duplicating React Query's job and creating sync bugs. Redux is powerful for event-sourced client state (undo/redo, collaborative editing) — not for caching API data.
1// State management decision framework23// 1. IS IT SERVER DATA (from an API)?4// → Use React Query / SWR. Do NOT put in global store.React Query caches this automatically — no Redux needed5const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });6const { data: orders } = useQuery({ queryKey: ['orders', userId], queryFn: fetchOrders });78// 2. IS IT URL STATE (current page/filters)?9// → Use URL search params. Shareable, bookmarkable, free.10const [searchParams, setSearchParams] = useSearchParams();11const role = searchParams.get('role') ?? 'all';URL state is free persistence and shareability — use it12const page = parseInt(searchParams.get('page') ?? '1');1314// 3. IS IT LOCAL UI STATE (open/closed, form values)?15// → useState or useReducer in the component.16const [isModalOpen, setIsModalOpen] = useState(false);1718// 4. IS IT SHARED UI STATE (theme, auth, notifications)?19// → React Context for simple cases, Zustand for complex.20const { theme, setTheme } = useThemeContext();2122// Zustand store — only for genuinely shared client state23interface UIStore {24sidebarCollapsed: boolean;25notificationQueue: Notification[];26toggleSidebar: () => void;27addNotification: (n: Notification) => void;28}2930const useUIStore = create<UIStore>((set) => ({31sidebarCollapsed: false,32notificationQueue: [],33toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),34addNotification: (n) => set((s) => ({35notificationQueue: [...s.notificationQueue, n]36})),37}));
Rendering strategies: CSR vs SSR vs SSG vs ISR
CSR (Client-Side Rendering): The server sends an empty HTML shell + JS bundle. React renders everything in the browser. Best for authenticated apps (dashboards, SaaS) where SEO does not matter and personalization is high. Downside: slow Time To Interactive (TBI) on slow devices.
SSR (Server-Side Rendering): Server runs React, sends fully-rendered HTML. Great for SEO, fast First Contentful Paint (FCP). But server must render every request — higher server CPU cost, harder caching.
SSG (Static Site Generation): HTML is generated at build time. Fastest possible delivery via CDN. Best for marketing pages, docs, blogs. Content can only change with a rebuild.
ISR (Incremental Static Regeneration): SSG + automatic background revalidation. Pages are served as static HTML but revalidated after a configurable interval. Best of both worlds for semi-dynamic content (product pages, news articles).
| Strategy | Initial Load | SEO | Personalization | Server Cost | Best For |
|---|---|---|---|---|---|
| CSR | Slow (JS + hydrate) | Poor | Excellent | Low | Dashboards, authenticated apps |
| SSR | Fast (server renders) | Excellent | Good | High | E-commerce, social media |
| SSG | Fastest (CDN) | Excellent | None | Minimal | Marketing, docs, blogs |
| ISR | Fast (CDN + revalidate) | Excellent | Limited | Low | Product pages, news articles |
Performance patterns: preventing re-render death
React re-renders a component and all its children whenever state or props change. In large trees, unnecessary re-renders kill performance. The tools:
React.memo: Prevent re-render of a component if its props have not changed (shallow comparison). Use on expensive components that receive the same props frequently.
useMemo: Memoize a computed value. Only recompute when dependencies change. Use for expensive computations (sorting 10,000 items, complex derived data). Avoid for cheap computations — the memoization overhead is not free.
useCallback: Memoize a function reference. Important when passing callbacks to `React.memo` components — without it, the callback reference changes every render, defeating memo.
Virtualization: Only render visible rows in long lists (react-window, react-virtual). A list of 10,000 items renders only 20-30 at a time. Essential for data tables and feeds.
Code splitting: `React.lazy` + `Suspense` to split the bundle. The initial bundle loads only the current page's code. Other pages are loaded on demand.
Profile before optimizing
Premature memoization is a performance anti-pattern. useMemo and useCallback have overhead. Use React DevTools Profiler to identify actual bottlenecks before adding memoization. Fixing a wrong problem makes your code worse, not better.
1import { memo, useMemo, useCallback } from 'react';2import { FixedSizeList as List } from 'react-window';34// ✅ Code splitting — only loads when /dashboard route is visited5const Dashboard = lazy(() => import('./pages/Dashboard'));67// ✅ React.memo — only re-renders when order prop changes8const OrderRow = memo(function OrderRow({memo is useless without useCallback on the onCancel prop9order,10onCancel11}: {12order: Order;13onCancel: (id: string) => void14}) {15return <div>{order.id}: {order.total}</div>;16});1718function OrdersPage({ userId }: { userId: string }) {19const { data: orders = [] } = useQuery({ queryKey: ['orders', userId], queryFn: fetchOrders });2021// ✅ useMemo — expensive sort only recomputed when orders change22const sortedOrders = useMemo(23() => [...orders].sort((a, b) => b.createdAt - a.createdAt),24[orders]25);26useCallback without memo on OrderRow is pointless overhead27// ✅ useCallback — stable reference so OrderRow.memo works28const handleCancel = useCallback(29(orderId: string) => cancelOrder(orderId),30[] // no dependencies — cancelOrder is stable31);3233// ✅ Virtualized list — renders only visible rowsreact-window renders only ~8 rows instead of 10,00034return (35<List36height={600}37itemCount={sortedOrders.length}38itemSize={72}39width="100%"40>41{({ index, style }) => (42<div style={style}>43<OrderRow order={sortedOrders[index]} onCancel={handleCancel} />44</div>45)}46</List>47);48}
Error boundaries and resilient UIs
JavaScript errors in React component trees crash the entire UI unless caught by an Error Boundary. An Error Boundary is a class component that implements `componentDidCatch` and renders a fallback UI when a child throws.
Strategy: place Error Boundaries at route level (prevent one page from crashing others), around "risky" widgets (data-dependent UIs that might fail), and around dynamic imports (code-split components that might fail to load).
React Suspense: The sibling of Error Boundaries for async states. Wrap async components with `<Suspense fallback={<Spinner />}>` — while the component is loading (data fetch, lazy import), the fallback renders. Combined with Error Boundary = resilient async UIs.
1import { Component, Suspense, lazy } from 'react';23// Error Boundary (class component — React requires this)4class ErrorBoundary extends Component<5{ fallback: ReactNode; onError?: (error: Error) => void; children: ReactNode },6{ hasError: boolean; error: Error | null }7> {8state = { hasError: false, error: null };910static getDerivedStateFromError(error: Error) {11return { hasError: true, error };12}1314componentDidCatch(error: Error, info: ErrorInfo) {componentDidCatch is the right place to report to Sentry/Datadog15// Report to Sentry, Datadog, etc.16this.props.onError?.(error);17reportError(error, { componentStack: info.componentStack });18}1920render() {21if (this.state.hasError) return this.props.fallback;22return this.props.children;23}24}2526// Usage: Error Boundary + Suspense for resilient lazy-loaded features27const Analytics = lazy(() => import('./Analytics'));2829function Dashboard() {30return (ErrorBoundary wraps Suspense — catches both render errors and failed lazy imports31<ErrorBoundary32fallback={<div>Analytics failed to load. <button>Retry</button></div>}33onError={(e) => console.error('Dashboard error:', e)}34>35<Suspense fallback={<AnalyticsSkeleton />}>36<Analytics />37</Suspense>38</ErrorBoundary>39);40}
How this might come up in interviews
Frontend architecture interviews test whether you can make technology choices and defend them, not just implement features.
Common questions:
- How would you architect a complex form with 20+ fields, multi-step validation, and API submission?
- When would you use SSR vs SSG vs CSR for a product page?
- How do you prevent prop drilling without overusing Context?
- Walk me through how React Query handles caching and revalidation.
- How would you debug a component that is re-rendering too often?
Strong answers include:
- Distinguishes server state from client state and uses the right tool for each
- Knows when NOT to use useMemo/useCallback
- Can explain hydration and its trade-offs
- Understands the cascade of renders and how to stop it with the right patterns
Red flags:
- Stores all state in Redux/Context regardless of type
- Cannot explain the difference between useEffect and useLayoutEffect
- Does not know what an Error Boundary does
- Suggests refactoring to class components to fix performance issues
Quick check · Frontend Architecture & State Management
1 / 1
A React component re-renders 50 times per second during a drag operation, causing jank. The component uses `useSelector` from Redux to read a value that changes on every mouse move. What is the MOST effective fix?
Key takeaways
- Separate UI components (dumb) from hooks (logic) from pages (orchestration)
- Server state → React Query. URL state → URLSearchParams. Local state → useState. Global UI → Zustand
- SSR for SEO + fast FCP, SSG for static, ISR for semi-dynamic, CSR for authenticated apps
- Profile before optimizing — useMemo/useCallback have overhead and are often misused
- Error Boundaries + Suspense create resilient UIs that degrade gracefully instead of crashing
- Virtualize any list with 100+ rows — react-window renders only visible items
From the books
Fluent React — Tejas Kumar (2024)
Chapter 8: Performance Optimization
React's rendering model is a tree diffing algorithm. Understanding what causes re-renders (reference equality, context updates, parent renders) makes you 10x better at performance optimization than blindly adding memo everywhere.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Discussion
Questions? Discuss in the community or start a thread below.
Join DiscordIn-app Q&A
Sign in to start or join a thread.