React Advanced Patterns: HOCs, Context, Compound Components & Performance
Production-ready React patterns ā Higher Order Components, Context architecture, compound components, React.memo, code splitting, and Suspense with real app examples.
Higher Order Components (HOCs)
A Higher Order Component is a function that takes a component and returns a new component with added behavior.
Component ā HOC ā Enhanced ComponentHOCs were the primary pattern before hooks. They're still useful for cross-cutting concerns (auth, logging, theming) that need to wrap existing components.
Real-World: withAuth HOC
function withAuth(WrappedComponent, requiredRole = null) {
function AuthenticatedComponent(props) {
const { user, isLoading } = useAuth();
if (isLoading) return <PageSpinner />;
if (!user) {
return <Navigate to="/login" state={{ from: location }} />;
}
if (requiredRole && user.role !== requiredRole) {
return <AccessDenied requiredRole={requiredRole} />;
}
return <WrappedComponent {...props} user={user} />;
}
// Preserve the display name for debugging
AuthenticatedComponent.displayName = `withAuth(${
WrappedComponent.displayName || WrappedComponent.name
})`;
return AuthenticatedComponent;
}
// Usage
const AdminDashboard = withAuth(Dashboard, "admin");
const UserProfile = withAuth(Profile);Real-World: withErrorBoundary HOC
function withErrorBoundary(WrappedComponent, FallbackComponent) {
class WithErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error("Error caught by HOC:", error, info);
reportToSentry(error, { componentStack: info.componentStack });
}
render() {
if (this.state.hasError) {
return (
<FallbackComponent
error={this.state.error}
retry={() => this.setState({ hasError: false, error: null })}
/>
);
}
return <WrappedComponent {...this.props} />;
}
}
return WithErrorBoundary;
}
// Usage
const SafeChart = withErrorBoundary(Chart, ChartErrorFallback);HOC vs Custom Hook ā When to Use Which
HOC:
ā Need to control rendering (auth redirect, error boundary)
ā Wrapping third-party components you can't modify
ā Class component compatibility
Custom Hook:
ā Sharing stateful logic between components
ā The consumer decides how to render
ā Composing multiple behaviorsCompound Components Pattern
Compound components let consumers control the inner structure while you manage the shared state. Think <select> + <option> or a tabs component.
Real-World: Tabs Component
// The shared state lives in a context
const TabsContext = createContext(null);
function Tabs({ defaultValue, children, onChange }) {
const [active, setActive] = useState(defaultValue);
const handleChange = (value) => {
setActive(value);
onChange?.(value);
};
return (
<TabsContext.Provider value={{ active, onChange: handleChange }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tabs__list" role="tablist">{children}</div>;
}
function Tab({ value, children, disabled }) {
const { active, onChange } = useContext(TabsContext);
const isActive = active === value;
return (
<button
role="tab"
aria-selected={isActive}
disabled={disabled}
className={`tabs__tab ${isActive ? "tabs__tab--active" : ""}`}
onClick={() => !disabled && onChange(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const { active } = useContext(TabsContext);
return active === value ? (
<div role="tabpanel" className="tabs__panel">
{children}
</div>
) : null;
}
// Attach as static properties for clean import
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage: Consumer controls the structure
function SettingsPage() {
return (
<Tabs defaultValue="profile" onChange={(v) => trackTabView(v)}>
<Tabs.List>
<Tabs.Tab value="profile">Profile</Tabs.Tab>
<Tabs.Tab value="security">Security</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
<Tabs.Tab value="api" disabled>API (coming soon)</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile"><ProfileSettings /></Tabs.Panel>
<Tabs.Panel value="security"><SecuritySettings /></Tabs.Panel>
<Tabs.Panel value="billing"><BillingSettings /></Tabs.Panel>
</Tabs>
);
}Context: Architecture for Scale
A single global context becomes a performance nightmare. Structure your context by domain.
Multi-Context Architecture
// Split by domain ā each context only re-renders its consumers
function AppProviders({ children }) {
return (
<AuthProvider>
<ThemeProvider>
<CartProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
}Context Performance: The Split Pattern
// ā Antipattern: one context causes all consumers to re-render
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
// Every state change re-renders ALL context consumers
return (
<AppContext.Provider value={{ user, theme, cart, setUser, setTheme, setCart }}>
{children}
</AppContext.Provider>
);
}
// ā
Split state and dispatch ā stable dispatch reference avoids re-renders
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
// dispatch never changes ā components that only dispatch don't re-render on state changes
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export const useCartState = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);
// A button that only adds to cart never re-renders when cart changes
function AddToCartButton({ product }) {
const dispatch = useCartDispatch(); // stable reference
return (
<button onClick={() => dispatch({ type: "ADD", payload: product })}>
Add to Cart
</button>
);
}React.memo and Rendering Optimization
// React.memo wraps a component ā skips re-render if props are the same
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
console.log("ProductCard rendered:", product.id);
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product)}>Add to Cart</button>
</div>
);
});
// Custom comparison: only re-render when price or stock changes
const LiveProductCard = React.memo(
ProductCard,
(prevProps, nextProps) =>
prevProps.product.price === nextProps.product.price &&
prevProps.product.inStock === nextProps.product.inStock
);
// The parent must use useCallback to prevent breaking memo
function ProductGrid({ products }) {
const dispatch = useCartDispatch();
// ā
Stable reference ā useCallback prevents ProductCard from re-rendering
const handleAddToCart = useCallback(
(product) => dispatch({ type: "ADD", payload: product }),
[dispatch]
);
return (
<div className="grid">
{products.map((p) => (
<ProductCard key={p.id} product={p} onAddToCart={handleAddToCart} />
))}
</div>
);
}Code Splitting and Lazy Loading
Don't ship all your JavaScript upfront. Split by route ā load only what's needed.
import { lazy, Suspense } from "react";
// ā
These bundles are loaded on demand
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Analytics = lazy(() => import("./pages/Analytics"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));
function App() {
return (
<Router>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</Router>
);
}
// Named exports work too ā you need a default export in the chunk
// In Analytics.tsx:
export default function Analytics() { /* ... */ }
// Preloading: kick off loading before user navigates
const AnalyticsPage = lazy(() => import("./pages/Analytics"));
function Sidebar() {
const preloadAnalytics = () => {
// Start loading the chunk on hover
import("./pages/Analytics");
};
return (
<nav>
<Link to="/analytics" onMouseEnter={preloadAnalytics}>
Analytics
</Link>
</nav>
);
}Suspense for Data Fetching (React 18+)
// With React Query or SWR, Suspense works for data too
function UserProfile({ userId }) {
// This "suspends" if data isn't ready ā bubbles up to nearest Suspense boundary
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
suspense: true,
});
return <ProfileCard user={user} />;
}
function ProfilePage({ userId }) {
return (
<ErrorBoundary fallback={<ProfileError />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
);
}Forwarding Refs
When a parent needs direct access to a child's DOM node:
// A reusable input that exposes its DOM node
const Input = React.forwardRef(function Input(
{ label, error, ...props },
ref
) {
return (
<div className="field">
{label && <label>{label}</label>}
<input ref={ref} {...props} className={error ? "input--error" : ""} />
{error && <span className="error">{error}</span>}
</div>
);
});
// Parent can focus it programmatically
function SearchPage() {
const searchRef = useRef(null);
const focusSearch = () => searchRef.current?.focus();
return (
<>
<button onClick={focusSearch}>Jump to Search</button>
<Input ref={searchRef} label="Search" placeholder="Type here..." />
</>
);
}useImperativeHandle ā Controlled Ref API
const VideoPlayer = React.forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null);
// Only expose specific methods ā not the raw DOM element
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (time) => { if (videoRef.current) videoRef.current.currentTime = time; },
getDuration: () => videoRef.current?.duration ?? 0,
}));
return <video ref={videoRef} src={src} />;
});
// Parent gets a clean API, not a raw DOM element
function VideoControls() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
<button onClick={() => playerRef.current.seek(0)}>Restart</button>
</>
);
}Portals: Rendering Outside the DOM Tree
Portals render children into a different DOM node ā essential for modals, tooltips, and dropdowns that need to escape CSS overflow or z-index constraints.
import { createPortal } from "react-dom";
function Modal({ isOpen, onClose, title, children }) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
useEffect(() => {
const handleEsc = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [onClose]);
if (!isOpen) return null;
// Renders inside #modal-root, not inside the parent's DOM tree
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="modal__header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close">ā</button>
</div>
<div className="modal__body">{children}</div>
</div>
</div>,
document.getElementById("modal-root")
);
}React Fiber: The Architecture Behind React
React Fiber (introduced in React 16) is the reconciliation engine. Key concepts:
Fiber nodes ā lightweight representations of components that store:
- Component type and props
- State and effect list
- Reference to parent, child, sibling fibers
Two-phase rendering:
- Render phase (interruptible) ā React traverses the fiber tree, computes what changed. Can be paused, resumed, aborted.
- Commit phase (synchronous) ā React applies DOM updates. Cannot be interrupted.
This is why features like concurrent rendering, time-slicing, and Suspense are possible ā the render phase can be interrupted to handle higher-priority updates.
User types in input (high priority)
ā
React pauses ongoing data render (low priority)
ā
Immediately processes the input keystroke
ā
Resumes data renderIn React 18, you opt into concurrent features explicitly:
import { useTransition, useDeferredValue } from "react";
function SearchResults({ query }) {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
const handleSearch = (value) => {
// Mark the results update as non-urgent
startTransition(() => {
setResults(heavyFilterOperation(value));
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}
// Or defer a value that's expensive to render
function FilteredList({ input }) {
const deferredInput = useDeferredValue(input);
const filtered = useMemo(
() => items.filter(item => item.name.includes(deferredInput)),
[deferredInput]
);
return <List items={filtered} />;
}Interview Questions Answered
Q: What's the difference between a HOC and a custom hook?
A HOC wraps a component and controls its rendering. A custom hook shares stateful logic but the consuming component controls rendering. HOCs can work with class components; hooks cannot. For new code, prefer custom hooks ā they're simpler and compose better.
Q: How does React Fiber improve upon the previous stack reconciler?
The old stack reconciler was synchronous and recursive ā once started, it couldn't be interrupted, blocking the main thread. Fiber breaks rendering into units of work that can be paused, resumed, and prioritized. This enables concurrent rendering, Suspense, and time-slicing.
Q: What problem do portals solve?
Portals solve CSS stacking context issues. A modal inside a component with overflow: hidden or a low z-index would be clipped. Portals render the modal's DOM inside #modal-root (at the body level) while keeping it in React's component tree ā so events still bubble normally through React's tree.
Q: When should you reach for useReducer over Context + useState?
Use useReducer when state transitions are complex. Use Context when multiple components need access to shared state. They're often combined: useReducer for the state machine logic, Context for distribution. Redux is essentially this pattern at scale with dev tools and middleware.
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.