Skip to content

Advanced React Behavior and Tests

Step 28 of 45

Composition and Behavior Reuse

Introduction

This is lesson 4 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). Once you can build components, manage state, and write hooks, a new design question appears: how do I share behavior between components without copying code and without creating a rigid hierarchy?

React's answer is composition: build bigger components by combining smaller ones, passing elements and functions as props. Over the years the community has used several patterns for reusing behavior: higher-order components, render props, and compound components. Today custom hooks cover most needs, but the older patterns remain in the Meta course material, in existing codebases, and in library APIs, so you must recognise and write them.

The scenario is original: Harbor Workshops needs tabs for the workshop details, a mouse-position tooltip for a seating map, an authentication guard for the booking page, and a flexible dialog.

Learning objectives

  1. Explain why React favours composition over inheritance.
  2. Use children and props to build flexible layout components.
  3. Write and use a higher-order component (HOC) and describe its drawbacks.
  4. Write and use the render props pattern, including function-as-children.
  5. Build a compound component that shares implicit state through context.
  6. Compare HOCs, render props, and custom hooks and choose among them.
  7. Control a component from the parent ("controlled") or let it manage itself ("uncontrolled").
  8. Recognise accessible requirements for reusable widgets such as tabs and dialogs.

1. Composition over inheritance

In some languages, you reuse behavior by extending a base class. React does not recommend deep component inheritance. Instead, a component receives other components or elements as props and places them where it wants. You have already used this with children.

function Panel({ heading, children }) {
  return (
    <section className="panel">
      <h2>{heading}</h2>
      {children}
    </section>
  );
}

function BookingPanel() {
  return (
    <Panel heading="Book your seat">
      <BookingForm />
    </Panel>
  );
}

BookingPanel is a specialised version of Panel, made by composing rather than extending. The panel needs no knowledge of the form.

When a component needs several "holes", use named props:

function SplitLayout({ left, right }) {
  return (
    <div className="split">
      <aside>{left}</aside>
      <main>{right}</main>
    </div>
  );
}

<SplitLayout left={<FilterMenu />} right={<WorkshopList />} />

2. Higher-order components

A higher-order component is a function that takes a component and returns a new component with added behavior.

function withLoadingState(WrappedComponent) {
  return function WithLoadingState({ isLoading, ...props }) {
    if (isLoading) return <p role="status">Loading...</p>;
    return <WrappedComponent {...props} />;
  };
}

const WorkshopListWithLoading = withLoadingState(WorkshopList);

<WorkshopListWithLoading isLoading={status === 'loading'} workshops={workshops} />

Conventions: the name starts with with, it forwards unrelated props with the spread, and it does not modify the wrapped component. You must create the enhanced component outside of render; calling withX(Component) inside another component creates a new component type on every render, which resets all state below it.

An authentication guard

function withAuth(WrappedComponent) {
  return function Guarded(props) {
    const { member } = useSession();
    if (!member) return <Navigate to="/login" replace />;
    return <WrappedComponent {...props} member={member} />;
  };
}

export default withAuth(BookingPage);

Strengths: the behavior is applied to many components in one line, and the wrapped component stays unaware of it. Weaknesses:

  • Props can collide silently (the HOC injects member and may overwrite a prop with the same name).
  • Several HOCs stack into "wrapper hell": withA(withB(withC(Page))) is hard to read and debug.
  • The origin of props is invisible when reading the component.

3. Render props

A render prop is a prop whose value is a function that returns JSX. The component handles behavior and calls the function with data; the caller decides what to render.

function MousePosition({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMove(event) {
      setPosition({ x: event.clientX, y: event.clientY });
    }
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);

  return render(position);
}

<MousePosition render={({ x, y }) => <p>Pointer at {x}, {y}</p>} />

The prop does not have to be named render. When children is the function, the pattern is called function as children:

<MousePosition>
  {({ x, y }) => <Tooltip left={x} top={y}>Row 4, seat 12</Tooltip>}
</MousePosition>

(The component would then call children(position).)

Compared with HOCs, render props make data flow visible at the call site and avoid prop-name collisions. The cost is nesting when several are combined, and the inline arrow creates a new function each render, which matters only if the component is memoised.

Equivalent custom hook

The same behavior is simpler as a hook:

function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const handleMove = e => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);
  return position;
}

function Tooltip() {
  const { x, y } = useMousePosition();
  return <div style={{ left: x, top: y }} className="tooltip">Row 4, seat 12</div>;
}

No wrapper, no nesting. This is why hooks replaced most HOC and render-prop use for logic sharing. Render props still shine when the reusable piece involves markup decisions the parent must control, for example a list component that lets callers render each row (renderItem).

4. Compound components

Some widgets are a family of parts that must work together: tabs, accordions, menus. A compound component exposes the parts as separate components that share state implicitly, usually through context.

const TabsContext = createContext(null);

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsContext value={{ active, setActive }}>
      <div className="tabs">{children}</div>
    </TabsContext>
  );
}

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

function Tab({ value, children }) {
  const { active, setActive } = useContext(TabsContext);
  const selected = active === value;
  return (
    <button
      role="tab"
      id={`tab-${value}`}
      aria-selected={selected}
      aria-controls={`panel-${value}`}
      tabIndex={selected ? 0 : -1}
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }) {
  const { active } = useContext(TabsContext);
  if (active !== value) return null;
  return (
    <div role="tabpanel" id={`panel-${value}`} aria-labelledby={`tab-${value}`}>
      {children}
    </div>
  );
}

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

Usage reads like HTML:

<Tabs defaultValue="details">
  <Tabs.List>
    <Tabs.Tab value="details">Details</Tabs.Tab>
    <Tabs.Tab value="reviews">Reviews</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="details"><p>Two hours, all materials included.</p></Tabs.Panel>
  <Tabs.Panel value="reviews"><p>4.8 average from 120 reviews.</p></Tabs.Panel>
</Tabs>

The caller chooses the order and extra content; the parts coordinate without the caller passing active to each one. The WAI-ARIA tabs pattern also expects arrow-key navigation between tabs; a production version adds a keyboard handler that moves focus among tab buttons.

Controlled and uncontrolled versions

Tabs above is uncontrolled: it owns the active state (defaultValue only sets the start). A controlled version lets the parent own it:

function Tabs({ value, defaultValue, onChange, children }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;
  const active = isControlled ? value : internal;

  function setActive(next) {
    if (!isControlled) setInternal(next);
    onChange?.(next);
  }
  ...
}

Supporting both is how many libraries behave: use defaultValue for simple cases and value plus onChange when the parent must synchronise the tab with the URL, for example.

5. Comparing the patterns

Pattern Reuses Strength Weakness Typical use today
Children / slots Layout Simplest, no extra concepts Only placement Cards, panels, layouts
Custom hook Stateful logic Flat, composable, no wrappers Cannot render markup Fetching, forms, subscriptions
HOC Logic + injected props Applies to many components at once Prop collisions, wrapper hell Legacy code, cross-cutting guards
Render prop Logic + caller-chosen markup Explicit data flow Nesting, inline functions renderItem lists, headless widgets
Compound component A family of related parts Declarative, flexible API Needs context, more design effort Tabs, accordion, menu, dialog

Decision guide: start with children and props; extract a hook for shared logic; use a render prop when the caller must control rendering per item; use a compound component for multi-part widgets; keep HOCs for existing code or wrappers that decide whether to render something at all.

6. A render prop for lists: renderItem

One place where render props remain the natural choice is a generic list that handles the repetitive parts (empty state, keys, loading) while the caller decides what each row looks like.

function DataList({ items, getKey, renderItem, emptyMessage = 'Nothing to show.' }) {
  if (items.length === 0) return <p>{emptyMessage}</p>;
  return (
    <ul>
      {items.map(item => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

<DataList
  items={instructors}
  getKey={i => i.id}
  renderItem={i => <strong>{i.name}</strong>}
  emptyMessage="No instructors yet."
/>

<DataList
  items={workshops}
  getKey={w => w.id}
  renderItem={w => <WorkshopRow workshop={w} onReserve={reserve} />}
/>

The same DataList serves two different data shapes. A hook cannot replace it here, because the reusable part is a piece of markup (the list wrapper and the empty state) with a hole in the middle. Notice the design decisions: getKey keeps the component independent of whether ids are called id, slug, or sku; emptyMessage has a default; and the component never mutates items.

7. Designing the public API of a reusable component

Every prop you add is a promise to future callers. Before publishing a component to your team, run this short review:

  1. Can the caller reach the common case in one line? Required props should be few.
  2. Are two props mutually exclusive? If isPrimary and isDanger cannot both be true, use variant.
  3. Does a prop exist only to inject text? Prefer children, which also supports formatting and translated content with markup.
  4. Can the caller extend behavior? Accept className, forward extra attributes with rest props, and call user-provided event handlers rather than replacing them.
  5. Is the accessibility built in? Labels, roles, and focus behavior should not depend on every caller remembering them.
  6. Is it documented by example? A short usage snippet in a comment or a story is worth more than a long description.

A component that survives this review can be reused for months without edits; a component that fails it will collect special cases until nobody dares to change it.

Worked example: a flexible dialog

Statement. The site needs a confirmation dialog in three places (cancel booking, delete account, leave unsaved form). The title, message, and buttons differ. Design a reusable component.

Step 1: identify what is shared. The overlay, focus handling, the close button, and the escape key. What differs: title, body, actions.

Step 2: use composition for the variable parts.

function Dialog({ open, title, onClose, children, actions }) {
  const ref = useRef(null);

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  return (
    <dialog ref={ref} aria-labelledby="dialog-title" onClose={onClose}>
      <h2 id="dialog-title">{title}</h2>
      <div>{children}</div>
      <div className="dialog__actions">{actions}</div>
    </dialog>
  );
}

Step 3: use it.

<Dialog
  open={confirming}
  title="Cancel this booking?"
  onClose={() => setConfirming(false)}
  actions={
    <>
      <button onClick={() => setConfirming(false)}>Keep booking</button>
      <button onClick={cancelBooking}>Cancel booking</button>
    </>
  }
>
  <p>You will lose your seat for Saturday at 10:00.</p>
</Dialog>

Step 4: why this is better. The native <dialog> element supplies focus trapping and the Escape key when opened with showModal. The component accepts content through children and actions, so it never needs a type="cancel" | "delete" prop. The onClose callback keeps the parent's state in sync when the user presses Escape. A version with title, message, confirmLabel, cancelLabel, onConfirm, onCancel props would grow with every new request.

Common pitfalls

  • Creating an HOC inside a component body, which remounts the subtree on each render.
  • Forgetting to forward props in a HOC (<Wrapped /> instead of <Wrapped {...props} />).
  • Overwriting props because the HOC injects a name that already exists.
  • Calling children as a function when the caller passed elements, or the reverse.
  • Using index-based coupling in compound components instead of explicit value props.
  • Skipping accessibility in custom widgets: roles, labels, keyboard support, focus management.
  • Mixing controlled and uncontrolled usage on one instance, so state is ambiguous.
  • Over-engineering: building a configurable component framework for something used once.

Exam tips

  • "A function that takes a component and returns a new component" is a higher-order component.
  • "A prop whose value is a function returning JSX" is a render prop.
  • If a question asks for the modern replacement of logic-sharing HOCs and render props, the answer is a custom hook.
  • A parent that shows or hides content with children is composition, not inheritance.
  • Compound components typically rely on context for the hidden shared state.

Mini case study: the guard that logs everyone out

Situation. A developer writes the authentication guard like this and uses it in the router:

function App() {
  const GuardedAccount = withAuth(AccountPage);
  return (
    <Routes>
      <Route path="/account" element={<GuardedAccount />} />
    </Routes>
  );
}

Users report that the account form loses everything they typed each time any state in App changes (for example a theme toggle).

Analysis. withAuth(AccountPage) executes inside App on every render, returning a new function each time. React compares component types by identity; a new type means the old subtree is unmounted and a new one mounted, destroying local state such as the form's text.

Fix. Create the enhanced component once, at module level:

const GuardedAccount = withAuth(AccountPage);

function App() { ... }

or, better, express the guard as a wrapper component rather than a HOC:

function RequireAuth({ children }) {
  const { member } = useSession();
  return member ? children : <Navigate to="/login" replace />;
}

<Route path="/account" element={<RequireAuth><AccountPage /></RequireAuth>} />

Why the second option is preferable. It uses plain composition, avoids prop injection, and is visible in the route definition.

Key terms

  • Composition: combining components by nesting and passing elements or functions as props.
  • Slot: a named prop that receives JSX.
  • Higher-order component (HOC): a function that returns an enhanced version of a component.
  • Render prop: a function prop that returns JSX, supplied by the caller.
  • Function as children: a render prop passed as children.
  • Compound component: a set of components that share hidden state to form one widget.
  • Controlled component: a component whose state is owned by the parent through props.
  • Uncontrolled component: a component that owns its state internally.
  • Wrapper hell: deeply nested wrappers that obscure the structure.
  • Headless component: logic without built-in styling or markup.

Summary checklist

  • I build variations by composition rather than inheritance.
  • I use children and slots for layout flexibility.
  • I can write a HOC and avoid creating it during render.
  • I can write and read render props and function-as-children.
  • I can build a compound component with context.
  • I choose hooks first for logic reuse and know when other patterns fit.
  • I understand controlled versus uncontrolled usage.
  • I add roles, labels, and keyboard support to custom widgets.

Official sources