Skip to content

Advanced React: Hooks, Composition, Testing, and Performance

Step 28 of 45

React Composition: Render Props, Accessible Tabs, and Dialogs

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 used this with children in reusable components and context providers.

JSX
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:

JSX
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.

JSX
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

JSX
import { Navigate } from 'react-router';
import { useSession } from './SessionContext.jsx';

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);

This guard controls client-side presentation only. The local demo member context is not proof of authentication; a server must independently authorize data and operations.

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.

JSX
function MousePosition({ render, children }) {
  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);
  }, []);

  const draw = render ?? children;
  if (typeof draw !== 'function') throw new Error('Pass a render function or function child');
  return draw(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:

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

The implementation accepts either render or a function child. This pointer-only readout demonstrates data flow, not a complete accessible tooltip: essential seat information must also be available through keyboard and touch interactions.

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:

JSX
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.

JSX
import { createContext, useContext, useId, useState } from 'react';
const TabsContext = createContext(null);

function useTabs() {
  const value = useContext(TabsContext);
  if (!value) throw new Error('Tab parts must be inside Tabs');
  return value;
}

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

function TabList({ children, label = 'Workshop information' }) {
  function handleKeyDown(event) {
    const list = event.currentTarget;
    const tabs = [...list.querySelectorAll('[role="tab"]:not(:disabled)')]
      .filter(tab => tab.closest('[role="tablist"]') === list);
    const current = tabs.indexOf(document.activeElement);
    if (current < 0) return;
    let next;
    if (event.key === 'ArrowRight') next = (current + 1) % tabs.length;
    else if (event.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = tabs.length - 1;
    else return;
    event.preventDefault();
    tabs[next].focus(); // onFocus activates this local, immediately available panel
  }
  return <div role="tablist" aria-label={label} onKeyDown={handleKeyDown}>{children}</div>;
}

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

function TabPanel({ value, children }) {
  const { active, baseId } = useTabs();
  return (
    <div role="tabpanel" id={`${baseId}-panel-${value}`}
         aria-labelledby={`${baseId}-tab-${value}`} hidden={active !== value} tabIndex={0}>
      {children}
    </div>
  );
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

Usage reads like HTML:

JSX
<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. This horizontal example implements Left/Right, Home, and End, with automatic activation for immediately available local panels. IDs are unique per instance, panels stay mounted while hidden, and tab values must be unique stable strings with a matching defaultValue. The static-text panels have tabIndex=0; adapt panel focusability to their content. Test keyboard behavior and naming before adopting the widget.

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:

JSX
// Replace the Tabs function above; keep the same part components.
function Tabs({ value, defaultValue, onChange, children }) {
  const [internal, setInternal] = useState(defaultValue);
  const baseId = useId();
  const isControlled = value !== undefined;
  const active = isControlled ? value : internal;
  function setActive(next) {
    if (!isControlled) setInternal(next);
    if (next !== active) onChange?.(next);
  }
  return (
    <TabsContext value={{ active, setActive, baseId }}>
      <div className="tabs">{children}</div>
    </TabsContext>
  );
}

Choose controlled or uncontrolled mode for the mounted lifetime. A controlled instance requires an onChange handler that updates value; defaultValue only initializes internal state. 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 Does not replace a structural component 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.

JSX
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.

Reuse semantic controls and accessible form errors inside the dialog.

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.

JSX
import { useEffect, useId, useRef } from 'react';

function Dialog({ open, title, onClose, children, actions }) {
  const ref = useRef(null);
  const titleId = useId();
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
    return () => { if (dialog.open) dialog.close(); };
  }, [open]);

  return (
    <dialog ref={ref} aria-labelledby={titleId}
            onCancel={event => { event.preventDefault(); onClose(); }}
            onClose={event => {
              // Ignore a queued cleanup close event after the dialog reopened.
              if (open && !event.currentTarget.open) onClose();
            }}>
      <h2 id={titleId}>{title}</h2>
      <button type="button" onClick={onClose}>Close dialog</button>
      <div>{children}</div>
      <div className="dialog__actions">{actions}</div>
    </dialog>
  );
}

Step 3: use it.

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

The usage assumes cancelBooking is a synchronous local operation. For a real request, await success and handle failure before closing.

Step 4: why this is better. Opening a native dialog with showModal supplies modal behavior, initial focus handling, and normally Escape dismissal. This controlled wrapper maps cancel events to the parent, cleans up the opened dialog, uses a unique title ID, and includes an explicit close button. Verify focus restoration to the trigger, or provide a logical fallback if that trigger disappears. 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.

Study 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:

JSX
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:

JSX
const GuardedAccount = withAuth(AccountPage);

function App() { ... }

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

JSX
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: behavior and accessibility logic without imposed visual styling; it may still render semantic 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