DEV Community

Cover image for Advanced React Patterns I Wish I Knew 5 Years Ago
Shudhanshu Raj
Shudhanshu Raj

Posted on

Advanced React Patterns I Wish I Knew 5 Years Ago

Five years of React. Hundreds of components. Dozens of refactors. And the lesson I keep re-learning? How you structure your components matters more than what's inside them.

In this post, I'll walk through four patterns I use regularly in production today — with real examples, their trade-offs, and when not to use them.


1. Compound Components

This is the pattern that changed how I think about component APIs entirely.

The Problem

You're building a <Select> or a <Tabs> component. You start with props:

<Tabs
  items={['Overview', 'Details', 'Reviews']}
  activeIndex={0}
  onSelect={handleSelect}
  renderContent={(index) => <div>{content[index]}</div>}
/>
Enter fullscreen mode Exit fullscreen mode

Two weeks later, a designer wants custom icons in the tab labels. Then someone needs a tab to be disabled. Then a badge count. Before long you have 15 props, a messy renderX prop API, and a component nobody wants to touch.

The Pattern

Compound components let the consumer control structure while the parent manages state:

// Usage — clean, readable, extensible
<Tabs defaultIndex={0}>
  <Tabs.List>
    <Tabs.Tab>Overview</Tabs.Tab>
    <Tabs.Tab disabled>Details <Badge>New</Badge></Tabs.Tab>
    <Tabs.Tab>Reviews</Tabs.Tab>
  </Tabs.List>

  <Tabs.Panels>
    <Tabs.Panel><Overview /></Tabs.Panel>
    <Tabs.Panel><Details /></Tabs.Panel>
    <Tabs.Panel><Reviews /></Tabs.Panel>
  </Tabs.Panels>
</Tabs>
Enter fullscreen mode Exit fullscreen mode

Here's the implementation using Context:

const TabsContext = createContext(null);

function Tabs({ defaultIndex = 0, children }) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);

  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div role="tablist" className="tab-list">{children}</div>;
}

function Tab({ children, disabled = false }) {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);
  const index = useTabIndex(); // tracks position via cloneElement or Context counter

  return (
    <button
      role="tab"
      aria-selected={activeIndex === index}
      disabled={disabled}
      onClick={() => !disabled && setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  return <div className="tab-panels">{children}</div>;
}

function TabPanel({ children }) {
  const { activeIndex } = useContext(TabsContext);
  const index = useTabIndex();

  return activeIndex === index ? <div role="tabpanel">{children}</div> : null;
}

// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
Enter fullscreen mode Exit fullscreen mode

Why It Works

  • Zero prop-drilling — Context handles shared state transparently.
  • Fully composable — consumers can put anything inside <Tabs.Tab>.
  • Open for extension, closed for modification — adding a <Tabs.Tab> variant doesn't touch the core. ### When NOT to Use It

Compound components shine for UI components with shared state (Tabs, Accordion, Dropdown, Modal). They're overkill for simple stateless components. Don't reach for this pattern reflexively.


2. The State Reducer Pattern

Kent C. Dodds popularised this one, and once you get it, you'll see the use case everywhere.

The Problem

You've built a polished useToggle hook. Then a consumer says:

"Can we prevent the toggle from turning off if a certain condition is met?"

You could add an allowToggleOff prop. Then they ask for disableOnMax. And now your hook has 6 options covering every edge case someone dreamed up — and your tests look like a horror movie.

The Pattern

Give consumers control over how state transitions happen by accepting a custom reducer:

// The hook
function useToggle({ reducer = toggleReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, { on: false });

  const toggle = () => dispatch({ type: 'TOGGLE' });
  const reset = () => dispatch({ type: 'RESET' });

  return { on: state.on, toggle, reset };
}

// Default reducer — normal behaviour
function toggleReducer(state, action) {
  switch (action.type) {
    case 'TOGGLE': return { on: !state.on };
    case 'RESET':  return { on: false };
    default: throw new Error(`Unhandled action: ${action.type}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now a consumer can override specific transitions without touching the hook:

function App() {
  const [timesToggled, setTimesToggled] = useState(0);

  function customReducer(state, action) {
    if (action.type === 'TOGGLE' && timesToggled >= 3) {
      // Prevent toggling after 3 times — no hook changes needed
      return state;
    }
    return toggleReducer(state, action); // Delegate everything else
  }

  const { on, toggle } = useToggle({ reducer: customReducer });

  return (
    <div>
      <p>Toggle is {on ? 'ON' : 'OFF'}</p>
      <p>Times toggled: {timesToggled}</p>
      <button onClick={() => { toggle(); setTimesToggled(c => c + 1); }}>
        Toggle
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why It Works

  • Inversion of control — the hook owns what can happen; the consumer controls when.
  • No breaking changes — adding new behaviour doesn't change the hook's public API.
  • Testable in isolation — you can test the default reducer separately from custom overrides. ### Real Production Use Case

I used this in a form library to let consumers block submission during async validation, throttle re-renders on fast inputs, or add optimistic updates — all without forking the core hook.


3. Headless Components (a.k.a. Renderless Components)

This is the pattern behind Radix UI, Headless UI, and React Aria. And once you use it, you'll never go back to shipping styled components in a shared library.

The Problem

Your design system ships a <Dropdown> component. Works great. Then a team needs a dark-mode version. Then mobile needs a full-screen overlay instead of a floating menu. Now you're maintaining three variants of the same logic — open/close state, keyboard navigation, accessibility roles — duplicated across all three.

The Pattern

Separate behaviour and accessibility from visual rendering:

// Headless hook — pure logic, zero opinions on markup
function useDropdown(items) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const triggerRef = useRef(null);
  const listRef = useRef(null);

  const open  = () => setIsOpen(true);
  const close = () => { setIsOpen(false); setActiveIndex(-1); };
  const toggle = () => (isOpen ? close() : open());

  const handleKeyDown = (e) => {
    if (!isOpen) return;
    if (e.key === 'ArrowDown') setActiveIndex(i => Math.min(i + 1, items.length - 1));
    if (e.key === 'ArrowUp')   setActiveIndex(i => Math.max(i - 1, 0));
    if (e.key === 'Escape')    close();
    if (e.key === 'Enter' && activeIndex >= 0) {
      items[activeIndex].onSelect();
      close();
    }
  };

  // Returns prop getters — the key to this pattern
  return {
    isOpen,
    activeIndex,
    getTriggerProps: () => ({
      ref: triggerRef,
      onClick: toggle,
      'aria-haspopup': 'listbox',
      'aria-expanded': isOpen,
    }),
    getMenuProps: () => ({
      ref: listRef,
      role: 'listbox',
      onKeyDown: handleKeyDown,
    }),
    getItemProps: (index) => ({
      role: 'option',
      'aria-selected': index === activeIndex,
      onClick: () => { items[index].onSelect(); close(); },
    }),
  };
}
Enter fullscreen mode Exit fullscreen mode

Now any team can render their own markup with full keyboard support and ARIA:

// Team A — desktop floating menu
function DesktopDropdown({ items, label }) {
  const { isOpen, getTriggerProps, getMenuProps, getItemProps } = useDropdown(items);

  return (
    <div className="dropdown">
      <button {...getTriggerProps()}>{label}</button>
      {isOpen && (
        <ul className="dropdown-menu" {...getMenuProps()}>
          {items.map((item, i) => (
            <li key={item.id} className="dropdown-item" {...getItemProps(i)}>
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Team B — mobile bottom sheet, same hook
function MobileDropdown({ items, label }) {
  const { isOpen, getTriggerProps, getMenuProps, getItemProps } = useDropdown(items);

  return (
    <>
      <button className="mobile-trigger" {...getTriggerProps()}>{label}</button>
      {isOpen && (
        <div className="bottom-sheet">
          <ul {...getMenuProps()}>
            {items.map((item, i) => (
              <li key={item.id} className="sheet-item" {...getItemProps(i)}>
                <span>{item.icon}</span> {item.label}
              </li>
            ))}
          </ul>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Prop Getter Pattern

Notice the getTriggerProps() / getItemProps() approach — this is the secret sauce. Instead of passing individual ARIA props, you return an object the consumer spreads. This means:

  1. You can add new ARIA attributes to the getter without breaking consumers.
  2. Consumers can merge their own handlers: {...getTriggerProps(), onFocus: myHandler}. ### When to Use It

Build headless when: you're building a shared library used across multiple teams or design languages, or when you're wrapping a complex interaction (date pickers, comboboxes, drag-and-drop).

Don't headless-ify everything. A one-off <UserCard> in your app doesn't need this.


4. Composition Over Configuration

This isn't a single pattern — it's a mindset shift that ties everything together.

The Configuration Trap

// This is a configuration component. It will never stop growing.
<DataTable
  data={rows}
  columns={columns}
  sortable
  filterable
  paginated
  pageSize={10}
  onRowClick={handler}
  rowClassName={(row) => row.urgent ? 'urgent' : ''}
  emptyState={<EmptyIllustration />}
  headerRenderer={(col) => <Tooltip title={col.hint}>{col.label}</Tooltip>}
  footerRenderer={() => <ExportButton />}
  // ...and it keeps going
/>
Enter fullscreen mode Exit fullscreen mode

Every new requirement becomes a new prop. The component's internals become a maze of conditionals. Onboarding a new engineer? Good luck.

The Composition Alternative

<Table>
  <Table.Header>
    {columns.map(col => (
      <Table.Column key={col.id} sortable={col.sortable}>
        <Tooltip title={col.hint}>{col.label}</Tooltip>
      </Table.Column>
    ))}
  </Table.Header>

  <Table.Body data={rows}>
    {(row) => (
      <Table.Row key={row.id} onClick={() => handleRowClick(row)} className={row.urgent ? 'urgent' : ''}>
        {columns.map(col => (
          <Table.Cell key={col.id}>{col.render(row)}</Table.Cell>
        ))}
      </Table.Row>
    )}
  </Table.Body>

  <Table.Footer>
    <Pagination total={total} pageSize={10} onChange={setPage} />
    <ExportButton data={rows} />
  </Table.Footer>
</Table>
Enter fullscreen mode Exit fullscreen mode

More verbose? Yes. But:

  • Adding a sticky footer doesn't require a stickyFooter prop.
  • Custom row grouping doesn't require a groupBy API.
  • Every piece is individually testable. ### The Rule of Thumb

If you catch yourself adding more than 3 render props or 5 boolean flags to a component, it's time to decompose. Composition is usually the answer.


Putting It All Together

These four patterns aren't independent — they complement each other:

Pattern Best For Core Trade-off
Compound Components Shared-state UI (Tabs, Accordion) More verbose usage, but infinitely composable
State Reducer Custom hooks with complex state logic Requires consumers to understand reducer shape
Headless Components Shared libraries across design systems More code up-front; massive long-term flexibility
Composition Any component that's growing too many props Verbose JSX; worth it at scale

None of these are silver bullets. The best engineers I've worked with aren't the ones who know the most patterns — they're the ones who know when patterns are the wrong tool and keep things simple.

But when your component starts growing a config API that rivals a webpack config? That's your signal. Reach for these.


Thanks for reading. If you found this useful, I write about real-world React architecture, performance, and engineering trade-offs. Drop a comment if you've used any of these patterns — or if you've been burned by them. Both stories are worth hearing.

Top comments (0)