Skip to main content
  1. Frontends/
  2. React Guides/

Beyond Hooks: Architecting Scalable UI with Advanced React Patterns

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

It’s 2025. By now, Hooks have effectively won the “state management wars” for local component logic. We all know useState and useEffect like the back of our hands. But here is the uncomfortable truth: Hooks alone do not make an architecture.

If you look at the source code of the most successful UI libraries today—Radix UI, Headless UI, or even shadcn/ui—you won’t just see a pile of custom hooks. You will see structural patterns that dictate how components communicate, render, and compose.

As senior developers, our job isn’t just to make things work; it’s to make things usable for other developers. We are building APIs, not just UIs.

In this deep dive, we are going back to the future. We will dissect three advanced patterns that have matured alongside Hooks: Compound Components, Render Props, and Higher-Order Components (HOCs). We will look at why they are arguably more relevant now than ever for building scalable design systems, and how to implement them with modern TypeScript and React 19 standards.

Prerequisites and Environment
#

Before we start architecting, ensure your environment is ready. We are writing modern, type-safe React.

  • Node.js: v20.x or higher (LTS).
  • React: v19.x.
  • TypeScript: v5.x.
  • Package Manager: pnpm (preferred for speed) or npm.

Setup Command:

If you want to code along, spin up a quick Vite project:

npm create vite@latest react-patterns-advanced -- --template react-ts
cd react-patterns-advanced
npm install

We will also use clsx and tailwind-merge for class handling in our examples, as this is the standard for modern component styling.

npm install clsx tailwind-merge

1. The Compound Component Pattern
#

If you have ever used the <select> and <option> HTML tags, you have used a compound component. The magic here is implicit state sharing. The parent knows about the children, and they communicate without you having to pass props down through 15 layers of DOM.

Why use it in 2025?
#

In the era of Design Systems, you want components that are declarative. You don’t want to pass a massive configuration object to a component. You want to compose it.

Bad (Config Driven):

// Hard to read, hard to customize structure
<Tabs data={[{ label: 'A', content: '...' }]} />

Good (Compound):

// Flexible, readable, composable
<Tabs>
  <Tabs.List>
    <Tabs.Trigger value="a">Section A</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="a">...</Tabs.Content>
</Tabs>

Implementation: The Accessible Accordion
#

Let’s build a production-grade Accordion component using the Compound Pattern and the Context API.

Step 1: Define the Context
#

First, we need a Context to hold the state of which item is currently open.

import React, { createContext, useContext, useState, ReactNode } from 'react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

// Utility for classes
function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// 1. Define Types
type AccordionContextType = {
  openItem: string | null;
  toggleItem: (value: string) => void;
};

// 2. Create Context
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);

// 3. Custom Hook for consumption with safety check
const useAccordion = () => {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error('Accordion components must be used within an <Accordion />');
  }
  return context;
};

Step 2: The Parent Component
#

The parent manages the state and provides the context. Note how we allow standard HTML attributes via React.HTMLAttributes<HTMLDivElement>.

interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
  defaultValue?: string;
}

const Accordion = ({ children, defaultValue, className, ...props }: AccordionProps) => {
  const [openItem, setOpenItem] = useState<string | null>(defaultValue || null);

  const toggleItem = (value: string) => {
    setOpenItem((prev) => (prev === value ? null : value));
  };

  return (
    <AccordionContext.Provider value={{ openItem, toggleItem }}>
      <div className={cn('flex flex-col gap-2', className)} {...props}>
        {children}
      </div>
    </AccordionContext.Provider>
  );
};

Step 3: The Sub-Components
#

Here is where the pattern shines. The Item, Trigger, and Content components consume the context implicitly.

// --- Accordion Item ---
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string;
  children: ReactNode;
}

const AccordionItem = ({ value, children, className, ...props }: AccordionItemProps) => {
  // We attach the value to the children via Context logic in Trigger/Content
  // But strictly, the Item is mostly a structural wrapper here.
  // We could pass 'value' down via context again if we wanted nested context,
  // but for simplicity, we'll Clone elements or require value on children.
  
  // PRO APPROACH: Use a strictly scoped context for the Item or just pass props if shallow.
  // For this demo, we will use a Context Provider for the Item to avoid prop drilling "value".
  
  return (
    <AccordionItemContext.Provider value={{ value }}>
      <div className={cn('border rounded-lg overflow-hidden', className)} {...props}>
        {children}
      </div>
    </AccordionItemContext.Provider>
  );
};

// We need a sub-context so Trigger and Content know which "Item" they belong to
const AccordionItemContext = createContext<{ value: string } | undefined>(undefined);

const useAccordionItem = () => {
  const context = useContext(AccordionItemContext);
  if (!context) throw new Error('AccordionItem components must be wrapped in <Accordion.Item>');
  return context;
};

// --- Accordion Trigger ---
const AccordionTrigger = ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
  const { openItem, toggleItem } = useAccordion();
  const { value } = useAccordionItem();
  const isOpen = openItem === value;

  return (
    <button
      onClick={() => toggleItem(value)}
      className={cn(
        'w-full p-4 text-left font-medium transition-colors hover:bg-gray-50 flex justify-between items-center',
        isOpen && 'bg-gray-50',
        className
      )}
      {...props}
    >
      {children}
      <span className={cn('transition-transform', isOpen && 'rotate-180')}></span>
    </button>
  );
};

// --- Accordion Content ---
const AccordionContent = ({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
  const { openItem } = useAccordion();
  const { value } = useAccordionItem();
  const isOpen = openItem === value;

  if (!isOpen) return null;

  return (
    <div className={cn('p-4 bg-white border-t text-sm text-gray-600', className)} {...props}>
      {children}
    </div>
  );
};

// Attach sub-components to main component for cleaner namespace
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

export { Accordion };

Visualizing the Data Flow
#

It is crucial to understand that data flows sideways (via Context) rather than strictly down the visual tree.

graph TD A[Accordion Root Provider] -->|Provides: openItem, toggleItem| B(Accordion.Item Wrapper) B -->|Provides: value| C[Accordion.Trigger] B -->|Provides: value| D[Accordion.Content] C -.->|Consumes: openItem, toggleItem| A C -.->|Consumes: value| B D -.->|Consumes: openItem| A D -.->|Consumes: value| B style A fill:#e1f5fe,stroke:#01579b,stroke-width:2px style B fill:#fff3e0,stroke:#e65100,stroke-width:2px style C fill:#f3e5f5,stroke:#4a148c style D fill:#f3e5f5,stroke:#4a148c

2. Render Props: Inversion of Control
#

“Wait,” I hear you ask, “didn’t Hooks kill Render Props?”

Yes and no. Hooks replaced Render Props for logic reuse (like useWindowSize vs <WindowSize>), but Render Props remain superior for rendering customization.

When you are building a generic List, Table, or Virtual Scroller, you (the library author) control the logic (iteration, windowing), but the user controls the UI representation.

The Use Case: A Generic Data List with Selection
#

Let’s build a ListBuilder that handles filtering and selection but lets the parent decide exactly how the list items look.

import React, { useState, useMemo } from 'react';

// Generic Type Definition
interface ListBuilderProps<T> {
  items: T[];
  filterFn: (item: T, query: string) => boolean;
  // The Render Prop
  renderItem: (item: T, isSelected: boolean, toggle: () => void) => React.ReactNode;
  renderEmpty?: () => React.ReactNode;
}

function ListBuilder<T extends { id: string | number }>({
  items,
  filterFn,
  renderItem,
  renderEmpty
}: ListBuilderProps<T>) {
  const [query, setQuery] = useState('');
  const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set());

  // Performance Optimization
  const filteredItems = useMemo(() => {
    return items.filter((item) => filterFn(item, query));
  }, [items, query, filterFn]);

  const toggleSelection = (id: string | number) => {
    const newSet = new Set(selectedIds);
    if (newSet.has(id)) {
      newSet.delete(id);
    } else {
      newSet.add(id);
    }
    setSelectedIds(newSet);
  };

  return (
    <div className="w-full max-w-md border rounded-lg p-4 shadow-sm bg-white">
      {/* Internal UI controlled by the component */}
      <input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="w-full p-2 mb-4 border rounded"
      />

      <div className="flex flex-col gap-2">
        {filteredItems.length === 0 && renderEmpty ? renderEmpty() : null}
        
        {filteredItems.map((item) => (
          <React.Fragment key={item.id}>
            {/* INVERSION OF CONTROL HERE */}
            {renderItem(
              item, 
              selectedIds.has(item.id), 
              () => toggleSelection(item.id)
            )}
          </React.Fragment>
        ))}
      </div>
      
      <div className="mt-4 text-xs text-gray-500">
        Selected count: {selectedIds.size}
      </div>
    </div>
  );
}

Usage Example
#

Notice how the consumer fully owns the layout of the rows.

interface User {
  id: number;
  name: string;
  role: string;
}

const users: User[] = [
  { id: 1, name: 'Alice', role: 'Admin' },
  { id: 2, name: 'Bob', role: 'User' },
  { id: 3, name: 'Charlie', role: 'User' },
];

export const UserList = () => {
  return (
    <ListBuilder
      items={users}
      filterFn={(user, q) => user.name.toLowerCase().includes(q.toLowerCase())}
      renderEmpty={() => <div className="text-center p-4">No users found</div>}
      renderItem={(user, isSelected, toggle) => (
        <div 
          onClick={toggle}
          className={`flex justify-between p-3 rounded cursor-pointer border ${
            isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200'
          }`}
        >
          <span className="font-bold">{user.name}</span>
          <span className="text-gray-500 text-sm">{user.role}</span>
        </div>
      )}
    />
  );
};

3. Higher-Order Components (HOCs): The Decorator
#

Higher-Order Components are functions that take a component and return a new component. They fell out of favor because they introduced “Wrapper Hell” in the DevTools and prop collisions were messy.

However, in 2025, they are still the best tool for Cross-Cutting Concerns that do not require UI coupling. If you need to wrap 50 different components with an Error Boundary, a Logger, or an Authorization Check, an HOC is cleaner than adding a hook and conditional logic inside every single component file.

Real-World Use Case: Role-Based Access Control (RBAC)
#

We want to protect components so they only render if the user has specific permissions.

import React, { ComponentType, useEffect, useState } from 'react';

// Mock Auth Hook
const useAuth = () => {
  return {
    user: { name: 'Admin User', roles: ['admin', 'editor'] },
    isLoading: false
  };
};

// HOC Type Definition
interface WithRBACProps {
  requiredRole: string;
}

// The HOC Function
export function withRBAC<P extends object>(
  WrappedComponent: ComponentType<P>,
  requiredRole: string
) {
  // Return a new component
  const WithRBAC = (props: P) => {
    const { user, isLoading } = useAuth();
    const [hasAccess, setHasAccess] = useState(false);

    useEffect(() => {
      if (user && user.roles.includes(requiredRole)) {
        setHasAccess(true);
      } else {
        setHasAccess(false);
      }
    }, [user, requiredRole]);

    if (isLoading) return <div>Loading permissions...</div>;

    if (!hasAccess) {
      // Return null or a Fallback UI
      return (
        <div className="p-4 bg-red-50 text-red-600 rounded border border-red-200">
           Access Denied: Requires {requiredRole} role.
        </div>
      );
    }

    // Spread props securely
    return <WrappedComponent {...props} />;
  };

  // Best Practice: Set display name for debugging
  const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
  WithRBAC.displayName = `withRBAC(${wrappedName})`;

  return WithRBAC;
}

Usage
#

const AdminDashboard = ({ title }: { title: string }) => {
  return <h1 className="text-2xl font-bold text-green-700">{title}</h1>;
};

// Create the guarded component
const ProtectedDashboard = withRBAC(AdminDashboard, 'admin');

// Usage in App
// <ProtectedDashboard title="Super Secret Data" />

The Great Comparison: When to Use What?
#

As an architect, your value lies in choosing the right tool. Here is the definitive breakdown for 2025.

Feature Compound Components Render Props Higher-Order Components Hooks
Primary Goal UI Composition & flexible HTML structure Rendering flexibility & Inversion of Control Logic Reuse (Cross-cutting) & Component Decoration Logic Reuse (State/Effects)
State Sharing Implicit (Context) Explicit (Arguments) Explicit (Props injection) Internal / Custom Hook
Complexity High (requires Context providers) Medium High (Typing can be tricky) Low to Medium
Best For Selects, Accordions, Tabs, Menus Virtual Lists, Form Libraries, Animation wrappers Auth Guards, Error Boundaries, Logging, Analytics Data fetching, Local state, Form logic
Cons “Provider Hell” if overused Can lead to “Callback Hell” in JSX Prop collisions, Wrapper Hell Doesn’t solve rendering composition

Performance & Best Practices in 2025
#

When implementing these patterns, performance pitfalls are real. Here is how to keep your app 60fps (or 120fps on modern displays).

1. Memoize Context Values (Compound Components)
#

In the Accordion example above, if Accordion re-renders, the value object passed to the Provider is recreated, causing all consumers to re-render.

Fix: Use useMemo.

const contextValue = useMemo(() => ({ 
  openItem, 
  toggleItem 
}), [openItem]); // toggleItem should be stable (useCallback) or defined outside if possible

return (
  <AccordionContext.Provider value={contextValue}>
    {children}
  </AccordionContext.Provider>
);

2. Preventing “Prop Drilling” in HOCs
#

When using HOCs, ensure you pass through all unrelated props using {...props}. TypeScript generics (<P extends object>) are essential here to ensure the resulting component maintains the correct prop types of the original component.

3. Render Props & Function Reference Identity
#

If you pass an inline arrow function to a render prop, it creates a new function on every render.

// ⚠️ Causes re-renders if ListBuilder uses React.memo
<ListBuilder renderItem={(item) => <Row item={item} />} />

If the child component is heavy, define the render function outside the component or use useCallback.


Conclusion
#

React has evolved significantly, but the fundamental principles of software composition remain unchanged.

  • Use Compound Components when you want to provide a flexible UI kit where users control the HTML structure.
  • Use Render Props when you own the logic but want to give the user complete control over how the data is visualized.
  • Use HOCs sparingly, primarily for wrapping components with cross-cutting concerns like permissions or error handling boundaries.

By mastering these patterns alongside Hooks, you move from being a React “user” to a React “architect.” You stop fighting the framework and start building libraries that your team will love to use.

What to read next:

  • React 19 Server Components vs Client Patterns
  • Advanced TypeScript Generics for React Developers

Production Note: The code examples provided here are ready for generic implementation. However, for a high-traffic production app, ensure you add comprehensive unit tests (Vitest/Jest) specifically targeting the edge cases of Context undefined states and prop collisions in HOCs.

Happy coding.