Skip to content
·5 min read

Why React Composition Beats Inheritance Every Time

Class hierarchies broke against UI and React never brought them back. Children, slots, and render props cover every case inheritance promises, without the coupling.

ReactArchitecturePatternsWeb Dev

The inheritance instinct, arriving in React from a class-based background:

class BasePanel extends React.Component {
  renderHeader() { return <h2>{this.props.title}</h2>; }
  renderBody()  { return null; } // subclass me!
  render() {
    return (
      <section>
        {this.renderHeader()}
        {this.renderBody()}
      </section>
    );
  }
}
 
class UserPanel extends BasePanel {
  renderBody() {
    return <UserList users={this.props.users} />; // ...and now we are coupled
  }
}

It works, until the day two subclasses need to share a body variant but not a header variant, and the hierarchy forks. React has an answer to every case inheritance promises, and none of them involve extends.

Step 1: Reach for children before anything else

Most "I need inheritance" moments are "I need to put different things in the middle":

function Panel({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section className="panel">
      <h2>{title}</h2>
      {children}
    </section>
  );
}
 
<Panel title="Users"><UserList users={users} /></Panel>
<Panel title="Billing"><InvoiceTable id={invoiceId} /></Panel>

The parent owns the frame, the caller owns the content, and neither knows about the other's internals. One Panel, infinite bodies, zero subclasses.

Step 2: Use named props as slots for multiple regions

When a layout needs more than one insertion point, take them as separate props:

function PageLayout({
  header,
  sidebar,
  children,
}: {
  header: ReactNode;
  sidebar?: ReactNode;
  children: ReactNode;
}) {
  return (
    <div className="page">
      <header>{header}</header>
      <div className="main">
        {sidebar && <aside>{sidebar}</aside>}
        <main>{children}</main>
      </div>
    </div>
  );
}

This is the composite pattern from every design system, and it is why Card never subclasses Panel: the variation is what you pass, not what you extend. Each slot is independently swappable, which is the thing the class hierarchy could never give you.

Step 3: Pass a render prop when the child needs the parent's data

Some components render content that depends on internal state:

function Popover({ button, children }: {
  button: ReactNode;
  children: (props: { close: () => void }) => ReactNode;
}) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(!open)}>{button}</button>
      {open && (
        <div className="popover">
          {children({ close: () => setOpen(false) })}
        </div>
      )}
    </>
  );
}
 
<Popover button="Filters">
  {({ close }) => <FilterMenu onDone={close} />}
</Popover>

The list knows its items, the popover knows its trigger position, the picker knows its selection. Hand the render decision back to the caller as a function prop instead of forcing a subclass to override a method.

Step 4: Share behavior with hooks, not base classes

Inheritance shares behavior by ancestry; hooks share it by composition:

// shared behavior, no ancestor required
function useAutosave(save: (draft: Draft) => Promise<void>, delay = 1000) {
  const timer = useRef<ReturnType<typeof setTimeout>>();
  const saveDraft = useCallback((draft: Draft) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => save(draft), delay);
  }, [save, delay]);
  useEffect(() => () => clearTimeout(timer.current), []);
  return saveDraft;
}
 
function Editor()  { const save = useAutosave(persistEditor);  /* ... */ }
function Ticket()  { const save = useAutosave(persistTicket);  /* ... */ }

A useAutosave hook drops into any component. A BaseComponent with autosave drags every subclass into a hierarchy that grows a new abstract method each sprint. When behavior is needed in two places, that is a hook, not a parent.

Step 5: Push special cases up the tree, not down

The inheritance instinct wants a subtype for each variation:

// wrong: a class diagram
<ProUserPanel />
<TrialUserPanel />
<AdminUserPanel />
 
// right: one frame, variations as data and JSX
<Panel title="Account">
  {plan === "pro" ? <ProFeatures /> : <UpgradeCta />}
</Panel>

Composition flips the problem: one Panel, and the page decides what goes inside based on the user. Variations become data and JSX, not a hierarchy. When the fourth user type arrives, you add a JSX branch instead of refactoring an inheritance tree.

The rule that covers everything: React composes with components, and every "extension point" is just a prop. Children for content, slots for layouts, render props for internal state, hooks for behavior. Nothing left for extends to do.

React codebase growing wrapper components or forked variants of the same panel? A composition pass usually deletes more code than it adds. Tell me about the component and I will tell you what it wants to be.