Advanced React Patterns for Scalable Applications
Every React developer hits the same wall eventually. The component that started as 50 lines is now 400. Props are drilling five levels deep. State is scattered everywhere. The codebase works, but nobody wants to touch it.
The solution is not a new framework — it is learning the patterns that make React scale. These are the same patterns powering Radix UI, Shadcn, React Hook Form, and every other library you rely on daily.
"Keep your components focused on how things look, and your hooks focused on how things work."
1. Compound Components: The API Design Pattern
Compound components let you build a group of components that share implicit state through context. The parent manages state; the children consume it. The consumer gets a clean, expressive API.
This is exactly how
<select><option>The Problem Without It
text// BAD: Massive prop surface, impossible to extend <Tabs items={[ { label: 'Profile', content: <ProfilePanel /> }, { label: 'Settings', content: <SettingsPanel /> }, ]} defaultTab={0} onTabChange={handleChange} tabClassName="..." contentClassName="..." activeTabClassName="..." disabledTabs={[1]} />
The Compound Component Solution
textimport React, { createContext, useContext, useState } from 'react'; // 1. Create shared context interface TabsContextValue { activeTab: string; setActiveTab: (id: string) => void; } const TabsContext = createContext<TabsContextValue | null>(null); const useTabs = () => { const ctx = useContext(TabsContext); if (!ctx) throw new Error('useTabs must be used within <Tabs>'); return ctx; }; // 2. Parent manages state, exposes via context function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) { const [activeTab, setActiveTab] = useState(defaultTab); return ( <TabsContext.Provider value={{ activeTab, setActiveTab }}> <div className="w-full">{children}</div> </TabsContext.Provider> ); } // 3. Children consume context — no prop drilling function TabList({ children }: { children: React.ReactNode }) { return <div className="flex gap-1 border-b border-white/10 mb-6">{children}</div>; } function Tab({ id, children }: { id: string; children: React.ReactNode }) { const { activeTab, setActiveTab } = useTabs(); const isActive = activeTab === id; return ( <button onClick={() => setActiveTab(id)} className={`px-4 py-2 text-sm font-semibold rounded-t-lg transition-colors ${ isActive ? 'text-white border-b-2 border-purple-500' : 'text-gray-400 hover:text-white' }`} > {children} </button> ); } function TabPanel({ id, children }: { id: string; children: React.ReactNode }) { const { activeTab } = useTabs(); if (activeTab !== id) return null; return <div className="animate-in fade-in duration-200">{children}</div>; } // 4. Attach sub-components as static properties Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panel = TabPanel; // 5. Consumer gets a clean, readable API export default function ProfilePage() { return ( <Tabs defaultTab="profile"> <Tabs.List> <Tabs.Tab id="profile">Profile</Tabs.Tab> <Tabs.Tab id="settings">Settings</Tabs.Tab> <Tabs.Tab id="billing">Billing</Tabs.Tab> </Tabs.List> <Tabs.Panel id="profile"><ProfilePanel /></Tabs.Panel> <Tabs.Panel id="settings"><SettingsPanel /></Tabs.Panel> <Tabs.Panel id="billing"><BillingPanel /></Tabs.Panel> </Tabs> ); }
2. Custom Hooks: Separating Logic from UI
The single most impactful pattern in modern React. A custom hook extracts stateful logic into a reusable function, leaving the component focused purely on rendering.
Data Fetching Hook
textimport { useState, useEffect, useCallback, useRef } from 'react'; interface FetchState<T> { data: T | null; loading: boolean; error: Error | null; refetch: () => void; } function useFetch<T>(url: string): FetchState<T> { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const abortRef = useRef<AbortController | null>(null); const fetchData = useCallback(async () => { // Cancel any in-flight request abortRef.current?.abort(); abortRef.current = new AbortController(); setLoading(true); setError(null); try { const res = await fetch(url, { signal: abortRef.current.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); setData(json); } catch (err) { if ((err as Error).name !== 'AbortError') { setError(err as Error); } } finally { setLoading(false); } }, [url]); useEffect(() => { fetchData(); return () => abortRef.current?.abort(); }, [fetchData]); return { data, loading, error, refetch: fetchData }; } // Component is now trivially simple function UserProfile({ userId }: { userId: string }) { const { data: user, loading, error, refetch } = useFetch<User>(`/api/users/${userId}`); if (loading) return <ProfileSkeleton />; if (error) return <ErrorState message={error.message} onRetry={refetch} />; if (!user) return null; return <div className="p-6"><h2>{user.name}</h2><p>{user.email}</p></div>; }
Debounced Search Hook
textimport { useState, useEffect } from 'react'; function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } // Usage: search only fires after user stops typing for 300ms function SearchBar() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); const { data: results } = useFetch<Result[]>( debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : '' ); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 outline-none focus:border-purple-500" /> {results?.map(r => <ResultItem key={r.id} result={r} />)} </div> ); }
3. Render Props: Logic Without Coupling
Render props pass a function as a prop, letting the parent control logic while the consumer controls rendering. Less common since hooks, but still the right tool for certain problems — especially when you need to share logic between class and function components, or expose imperative handles.
text// A mouse tracker that doesn't care how its data is displayed interface MousePosition { x: number; y: number } function MouseTracker({ render }: { render: (pos: MousePosition) => React.ReactNode }) { const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 }); return ( <div className="w-full h-64 relative" onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })} > {render(position)} </div> ); } // Consumer decides what to render with the data <MouseTracker render={({ x, y }) => ( <div className="absolute w-4 h-4 rounded-full bg-purple-500 pointer-events-none -translate-x-1/2 -translate-y-1/2" style={{ left: x, top: y }} /> )} />
4. Higher-Order Components: Cross-Cutting Concerns
HOCs wrap a component and inject behavior. They are the right pattern for concerns that span many components — auth guards, analytics tracking, error boundaries, feature flags.
textimport { ComponentType, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/hooks/useAuth'; // Auth guard HOC function withAuth<P extends object>( WrappedComponent: ComponentType<P>, options: { redirectTo?: string; requiredRole?: string } = {} ) { const { redirectTo = '/login', requiredRole } = options; function AuthGuard(props: P) { const { user, loading } = useAuth(); const router = useRouter(); useEffect(() => { if (!loading && !user) router.replace(redirectTo); if (!loading && requiredRole && user?.role !== requiredRole) { router.replace('/unauthorized'); } }, [user, loading, router]); if (loading) return <FullPageSpinner />; if (!user) return null; return <WrappedComponent {...props} />; } AuthGuard.displayName = `withAuth(${WrappedComponent.displayName ?? WrappedComponent.name})`; return AuthGuard; } // Analytics HOC — tracks page views automatically function withAnalytics<P extends object>( WrappedComponent: ComponentType<P>, pageName: string ) { function TrackedComponent(props: P) { useEffect(() => { analytics.track('page_view', { page: pageName, timestamp: Date.now() }); }, []); return <WrappedComponent {...props} />; } TrackedComponent.displayName = `withAnalytics(${WrappedComponent.name})`; return TrackedComponent; } // Compose multiple HOCs cleanly const AdminDashboard = withAnalytics( withAuth(DashboardContent, { requiredRole: 'admin' }), 'admin_dashboard' );
displayName5. useMemo and useCallback: When They Actually Help
These are the most misused hooks in React. Most developers add them everywhere "for performance." That is wrong — they have a cost (memory + comparison overhead) and only pay off in specific situations.
text// When useMemo is WORTH IT: expensive computation function DataGrid({ rows, filters }: { rows: Row[]; filters: Filter[] }) { // This runs on every render without useMemo // With 10,000 rows and complex filters, that's expensive const filteredRows = useMemo( () => rows.filter(row => filters.every(f => f.test(row))), [rows, filters] // Only recompute when rows or filters change ); return <VirtualList items={filteredRows} />; } // When useCallback is WORTH IT: stable reference for child that uses React.memo const MemoizedChild = React.memo(function Child({ onAction }: { onAction: () => void }) { return <button onClick={onAction}>Action</button>; }); function Parent() { const [count, setCount] = useState(0); // Without useCallback: new function reference every render → MemoizedChild re-renders anyway // With useCallback: stable reference → MemoizedChild only re-renders when deps change const handleAction = useCallback(() => { console.log('action triggered'); }, []); // No deps = stable forever return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> <MemoizedChild onAction={handleAction} /> </div> ); }
useMemouseCallbackReact.memo6. The State Reducer Pattern
Give consumers control over how state updates work — without exposing the full state machine. Used by React Hook Form, Downshift, and other headless libraries.
texttype Action = | { type: 'INCREMENT' } | { type: 'DECREMENT' } | { type: 'RESET' }; interface CounterState { count: number } function defaultReducer(state: CounterState, action: Action): CounterState { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; case 'RESET': return { count: 0 }; default: return state; } } function useCounter( initialCount = 0, // Consumer can override how any action is handled reducer: (state: CounterState, action: Action) => CounterState = defaultReducer ) { const [state, dispatch] = useReducer(reducer, { count: initialCount }); return { count: state.count, increment: () => dispatch({ type: 'INCREMENT' }), decrement: () => dispatch({ type: 'DECREMENT' }), reset: () => dispatch({ type: 'RESET' }), }; } // Consumer can cap the counter at 10 without touching the hook internals function CappedCounter() { const { count, increment, decrement } = useCounter(0, (state, action) => { const next = defaultReducer(state, action); return { count: Math.min(Math.max(next.count, 0), 10) }; // clamp 0-10 }); return ( <div className="flex items-center gap-4"> <button onClick={decrement} className="px-4 py-2 rounded-xl bg-white/10">-</button> <span className="text-2xl font-bold w-8 text-center">{count}</span> <button onClick={increment} className="px-4 py-2 rounded-xl bg-white/10">+</button> </div> ); }
7. Watch: React Patterns in Depth

Pattern Decision Guide
| Pattern | Use when |
|---|---|
| Compound Components | Building UI kits, shared state between sibling components |
| Custom Hooks | Reusing stateful logic across multiple components |
| Render Props | Sharing logic when the consumer controls rendering |
| HOC | Cross-cutting concerns: auth, analytics, error boundaries |
| State Reducer | Giving consumers control over state transitions |
| useMemo / useCallback | Expensive computations or stable refs for memoized children |
Mastering these patterns does not mean using all of them everywhere. It means knowing which tool fits the problem — and reaching for it confidently when the time comes.

Vighnesh Salunkhe
"Passionate about building scalable web applications and exploring the intersection of AI and human creativity."
Join the Conversation
Share your thoughts or ask a question