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
- Explain why React favours composition over inheritance.
- Use
childrenand props to build flexible layout components. - Write and use a higher-order component (HOC) and describe its drawbacks.
- Write and use the render props pattern, including function-as-children.
- Build a compound component that shares implicit state through context.
- Compare HOCs, render props, and custom hooks and choose among them.
- Control a component from the parent ("controlled") or let it manage itself ("uncontrolled").
- 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
memberand 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:
- Can the caller reach the common case in one line? Required props should be few.
- Are two props mutually exclusive? If
isPrimaryandisDangercannot both be true, usevariant. - Does a prop exist only to inject text? Prefer
children, which also supports formatting and translated content with markup. - Can the caller extend behavior? Accept
className, forward extra attributes with rest props, and call user-provided event handlers rather than replacing them. - Is the accessibility built in? Labels, roles, and focus behavior should not depend on every caller remembering them.
- 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
childrenas a function when the caller passed elements, or the reverse. - Using index-based coupling in compound components instead of explicit
valueprops. - 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
childrenis 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
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 6: Advanced React (opens in a new tab)
- React: Passing Props to a Component (opens in a new tab)
- React: Reusing Logic with Custom Hooks (opens in a new tab)
- React: Legacy patterns (Higher-Order Components) (opens in a new tab)
- React: Legacy Render Props (opens in a new tab)
- WAI-ARIA Authoring Practices: Tabs (opens in a new tab)
- MDN: the
<dialog>element (opens in a new tab)
