Back to blog
Frontend Engineeringadvanced

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.

LearnixoApril 13, 202610 min read
ReactHOCContextCompound ComponentsReact.memoSuspenseCode Splitting
Share:š•

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 Component

HOCs 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

JSX
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

JSX
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 behaviors

Compound 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

JSX
// 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

JSX
// 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

JSX
// āŒ 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

JSX
// 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.

JSX
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+)

JSX
// 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:

JSX
// 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

JSX
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.

JSX
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:

  1. Render phase (interruptible) — React traverses the fiber tree, computes what changed. Can be paused, resumed, aborted.
  2. 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 render

In React 18, you opt into concurrent features explicitly:

JSX
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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.