Advanced Component Patterns: A Reference

By Odilon10 min read

Advanced Component Patterns: A Reference#

Four patterns I keep reaching for. Less narrative than my usual posts -- this is meant as a reference you can come back to. Code-heavy by design.

TL;DR

  • Compound components: shared implicit state via context, consumer controls composition
  • Headless hooks: logic separated from UI entirely -- the pattern behind every good UI library
  • Render props (evolved): still useful when the consumer needs to control rendering based on derived state
  • Polymorphic components: change the underlying HTML element without losing type safety

Compound Components#

The idea: multiple components share state through context, and the consumer controls how they're composed.

tsx
import React, { createContext, useContext, useState, useId } from "react";

interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
  baseId: string;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs compound components must be used inside <Tabs>");
  return ctx;
}

interface TabsProps {
  defaultTab: string;
  children: React.ReactNode;
}

export function Tabs({ defaultTab, children }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  const baseId = useId();

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

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

export function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab, setActiveTab, baseId } = useTabs();
  const isActive = activeTab === id;

  return (
    <button
      role="tab"
      id={`${baseId}-tab-${id}`}
      aria-controls={`${baseId}-panel-${id}`}
      aria-selected={isActive}
      onClick={() => setActiveTab(id)}
      className={isActive ? "tab tab--active" : "tab"}
    >
      {children}
    </button>
  );
}

export function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab, baseId } = useTabs();
  if (activeTab !== id) return null;

  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${id}`}
      aria-labelledby={`${baseId}-tab-${id}`}
    >
      {children}
    </div>
  );
}

Usage:

tsx
<Tabs defaultTab="overview">
  <TabList>
    <Tab id="overview">Overview</Tab>
    <Tab id="activity">Activity</Tab>
    <Tab id="settings">Settings</Tab>
  </TabList>
  <TabPanel id="overview"><ProjectOverview project={project} /></TabPanel>
  <TabPanel id="activity"><ActivityFeed events={events} /></TabPanel>
  <TabPanel id="settings"><ProjectSettings project={project} /></TabPanel>
</Tabs>

Consumer controls the markup. Reorder tabs, add icons, wrap panels in grid layouts -- none of it requires changes to Tabs internals. That's the payoff.

Render Props (The Hooks Version)#

Hooks eliminated most render prop use cases. But render props still make sense when the consumer controls what's rendered based on derived state from the component.

A DataTable that exposes sort/filter/pagination but lets you decide the layout:

tsx
interface DataTableRenderProps<T> {
  rows: T[];
  sortColumn: keyof T | null;
  sortDirection: "asc" | "desc";
  onSort: (column: keyof T) => void;
  page: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}

interface DataTableProps<T> {
  data: T[];
  pageSize?: number;
  children: (props: DataTableRenderProps<T>) => React.ReactNode;
}

export function DataTable<T extends Record<string, unknown>>({
  data,
  pageSize = 20,
  children,
}: DataTableProps<T>) {
  const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
  const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
  const [page, setPage] = useState(1);

  const sorted = useMemo(() => {
    if (!sortColumn) return data;
    return [...data].sort((a, b) => {
      const av = a[sortColumn];
      const bv = b[sortColumn];
      const cmp = av < bv ? -1 : av > bv ? 1 : 0;
      return sortDirection === "asc" ? cmp : -cmp;
    });
  }, [data, sortColumn, sortDirection]);

  const totalPages = Math.ceil(sorted.length / pageSize);
  const rows = sorted.slice((page - 1) * pageSize, page * pageSize);

  function onSort(column: keyof T) {
    if (sortColumn === column) {
      setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortColumn(column);
      setSortDirection("asc");
    }
    setPage(1); // reset to first page on sort change
  }

  return (
    <>{children({ rows, sortColumn, sortDirection, onSort, page, totalPages, onPageChange: setPage })}</>
  );
}

The caller owns the entire table layout. The component owns only the logic.

Headless Hooks#

The most underused pattern. A headless hook encapsulates all stateful logic for a UI concept and returns props/handlers -- without rendering anything. This is how Downshift works. This is how Radix UI, Headless UI, and React Aria work.

tsx
interface UseDisclosureOptions {
  defaultOpen?: boolean;
  onOpen?: () => void;
  onClose?: () => void;
}

interface UseDisclosureReturn {
  isOpen: boolean;
  open: () => void;
  close: () => void;
  toggle: () => void;
  getToggleProps: (overrides?: React.HTMLAttributes<HTMLElement>) => {
    onClick: () => void;
    "aria-expanded": boolean;
    "aria-controls"?: string;
  };
  getContentProps: (id: string) => {
    id: string;
    hidden: boolean;
  };
}

export function useDisclosure({
  defaultOpen = false,
  onOpen,
  onClose,
}: UseDisclosureOptions = {}): UseDisclosureReturn {
  const [isOpen, setIsOpen] = useState(defaultOpen);

  const open = useCallback(() => {
    setIsOpen(true);
    onOpen?.();
  }, [onOpen]);

  const close = useCallback(() => {
    setIsOpen(false);
    onClose?.();
  }, [onClose]);

  const toggle = useCallback(() => {
    setIsOpen((prev) => {
      const next = !prev;
      if (next) onOpen?.();
      else onClose?.();
      return next;
    });
  }, [onOpen, onClose]);

  const getToggleProps = useCallback(
    (overrides: React.HTMLAttributes<HTMLElement> = {}) => ({
      ...overrides,
      onClick: toggle,
      "aria-expanded": isOpen,
    }),
    [toggle, isOpen]
  );

  const getContentProps = useCallback(
    (id: string) => ({
      id,
      hidden: !isOpen,
    }),
    [isOpen]
  );

  return { isOpen, open, close, toggle, getToggleProps, getContentProps };
}

Same hook, completely different UI:

tsx
// Accordion
function AccordionItem({ title, children }: { title: string; children: React.ReactNode }) {
  const { getToggleProps, getContentProps } = useDisclosure();
  return (
    <div>
      <button {...getToggleProps()}>{title}</button>
      <div {...getContentProps("accordion-content")}>{children}</div>
    </div>
  );
}

// Command palette trigger
function CommandPaletteTrigger() {
  const { open, isOpen } = useDisclosure({
    onOpen: () => trackEvent("command_palette_opened"),
  });
  return <button onClick={open} disabled={isOpen}>Open Command Palette</button>;
}

Polymorphic Components#

A Text that can be h1 through h6, p, or span -- with correct HTML attribute types for each:

tsx
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"];

type AsProp<C extends React.ElementType> = {
  as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProp<
  C extends React.ElementType,
  Props = Record<string, never>
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

type PolymorphicComponentPropWithRef<
  C extends React.ElementType,
  Props = Record<string, never>
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };

type TextOwnProps = {
  color?: "primary" | "muted" | "danger";
  size?: "sm" | "md" | "lg" | "xl";
};

type TextProps<C extends React.ElementType> = PolymorphicComponentPropWithRef<C, TextOwnProps>;

export const Text = React.forwardRef(function Text<C extends React.ElementType = "span">(
  { as, color = "primary", size = "md", children, ...rest }: TextProps<C>,
  ref: PolymorphicRef<C>
) {
  const Component = as ?? "span";
  return (
    <Component
      ref={ref}
      className={`text text--${color} text--${size}`}
      {...rest}
    >
      {children}
    </Component>
  );
}) as <C extends React.ElementType = "span">(props: TextProps<C>) => React.ReactElement | null;

<Text as="h1" id="page-title"> gets h1 HTML attributes. <Text as="a" href="/about"> gets anchor props including href. TypeScript catches you passing href to a button or type="submit" to an a tag.

The type gymnastics look verbose. They are. But they catch real bugs.


Quick note on when to reach for each pattern:

  • Headless hook first -- gives you something testable immediately
  • Compound components when multiple components need to share that state
  • Render props when the consumer needs to control rendering based on derived state
  • Polymorphic only when your design system genuinely needs it (the TypeScript cost is real)

Don't over-engineer compound components for things with one clear rendering shape. And always memoize context values -- if the provider creates a new object on every render, every consumer re-renders:

tsx
// Bad: new object every render
<TabsContext.Provider value={{ activeTab, setActiveTab, baseId }}>

// Good: stable reference
const value = useMemo(
  () => ({ activeTab, setActiveTab, baseId }),
  [activeTab, baseId]
);
<TabsContext.Provider value={value}>
Advanced Component Patterns: A Reference | Blog