Creating a reusable component in React usually starts off well; we always have a clear idea: a few props, some content and an interaction. You use it on a couple of screens and it fits, nothing out of the ordinary. The problem comes when one screen needs the button moved, another needs a different header, and a third needs the content expanded with an extra action.
No single requirement seems like a big deal. You add a prop, solve the problem and move on. Until you open the component again and find twenty props and a handful of conditions you have to read several times to figure out which combinations it supports.
That kind of situation is where the Compound Components pattern becomes interesting. It lets you split the interface into pieces that can be arranged in different ways while still sharing behaviour. We will illustrate it with a collapsible panel: it is enough to cover composition, state, API design and TypeScript without building an entire library.
Note: The examples are written and tested with React 19.2 and TypeScript with @types/react 19. Where an API does not exist in React 18, the alternative is indicated.
The problem with over-configured components
This panel is fairly straightforward:
<Panel
title="Shipping information"
description="Check delivery times and conditions."
/>
A title and a description would probably be enough for its first uses. But variants start coming in and it ends up like this:
<Panel
title="Shipping information"
description="Check delivery times and conditions."
showIcon
iconPosition="left"
showBadge
badgeText="Free"
collapsible
defaultOpen
showFooter
footerText="Applies to orders over €50"
showAction
actionLabel="View conditions"
onAction={handleConditions}
actionPosition="footer"
/>
The tricky part here is having to describe the structure through flags. To place an action, you have to state that it exists, give it a label, say where it goes and pass what it will do when clicked. And what happens when two buttons are needed?
defaultOpen, on the other hand, has a fairly clear purpose: setting the initial state. The problem is not that the component receives props, but how much we are trying to express with them.
Internally, the build-up usually looks something like this:
{showFooter && (
<footer>
{footerText && <p>{footerText}</p>}
{showAction && actionPosition === "footer" && (
<button onClick={onAction}>
{actionLabel}
</button>
)}
</footer>
)}
If showFooter is false and actionPosition is « footer », we already have a combination that forces us to think about what should happen. Each variant adds another path, and the component ends up taking on the presentation decisions of every screen where it is used.
This can happen without any prop drilling: all twenty props could be consumed in the same component. The problem here is that we are using props to describe the entire structure.
What are Compound Components?
They are components designed to be used together, with one responsibility per piece and a contract that defines how they relate to each other. For our panel, the API could look like this:
<Panel defaultOpen>
<Panel.Trigger>
Shipping information
</Panel.Trigger>
<Panel.Content>
<p>Check delivery times and conditions.</p>
</Panel.Content>
</Panel>
Panel coordinates the state, Panel.Trigger opens and closes it, and Panel.Content displays the content. Whoever uses it writes the composition they need.
The dot notation makes that relationship visible. We could export PanelRoot, PanelTrigger and PanelContent separately and still be using the same pattern; adding functions as properties of another function only organises the JavaScript API. To share state, we will use Context.
From a monolithic component to a compound API
With the pieces above, the panel full of options can be written like this:
<Panel defaultOpen>
<div className="panel-heading">
<Panel.Trigger>
Shipping information
</Panel.Trigger>
<span className="badge">Free</span>
</div>
<Panel.Content>
<p>Check delivery times and conditions.</p>
<footer>
<p>Applies to orders over €50.</p>
<a href="/shipping-conditions">
View conditions
</a>
</footer>
</Panel.Content>
</Panel>
There is more JSX than in the first two-prop example, but now you can see where everything is. Adding another action means writing it in the right place; removing the footer means deleting the element. There is no need to modify the base component for every content variation.
We keep defaultOpen on the parent because it is still a behavioural option. The text and layout live in the screen that uses it.
Nor do we need to create Panel.Badge, Panel.Description, Panel.Footer and Panel.Link. Plain HTML works fine here. If that footer later needs shared styles and behaviour, it can be extracted at that point; turning every tag into a piece of the API adds work and does not always help whoever consumes it.
Sharing state between Compound Components
The trigger needs to know whether the panel is open and be able to change it. The content only needs to read the state. We will give them access through a context and a hook:
import {
createContext,
useContext,
useId,
useState,
type ReactNode,
} from "react";
type PanelContextValue = {
open: boolean;
contentId: string;
setOpen: (open: boolean) => void;
};
const PanelContext =
createContext<PanelContextValue | null>(null);
function usePanelContext() {
const context = useContext(PanelContext);
if (context === null) {
throw new Error(
"Panel components must be used within <Panel>."
);
}
return context;
}
The hook’s error will warn us if we use a piece outside of Panel. I would rather run into that message while testing the screen than into a button that does nothing because the context has an empty function as its default value.
The first version of the root component will hold its own state:
type PanelRootProps = {
children: ReactNode;
defaultOpen?: boolean;
};
function PanelRoot({
children,
defaultOpen = false,
}: PanelRootProps) {
const [open, setOpen] = useState(defaultOpen);
const contentId = useId();
return (
<PanelContext.Provider
value={{ open, setOpen, contentId }}
>
<div className="panel">{children}</div>
</PanelContext.Provider>
);
}
And these are the two pieces that consume it:
function PanelTrigger({
children,
}: {
children: ReactNode;
}) {
const { open, setOpen, contentId } = usePanelContext();
return (
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen(!open)}
>
{children}
</button>
);
}
function PanelContent({
children,
}: {
children: ReactNode;
}) {
const { open, contentId } = usePanelContext();
return (
<div id={contentId} hidden={!open}>
{children}
</div>
);
}
export const Panel = Object.assign(PanelRoot, {
Trigger: PanelTrigger,
Content: PanelContent,
});
All three snippets go in the same file. At the end, Object.assign attaches Trigger and Content to the root component, preserving their types in TypeScript.
For the trigger we use a native button, with its built-in keyboard activation. aria-expanded indicates whether it is open and aria-controls links it to the content through the identifier generated by useId. This implementation supports one trigger and one content element per root.
I have used hidden to hide the content without unmounting it. That preserves, for example, the local state of a form when the panel is closed. It also keeps its effects running, which is worth bearing in mind if there is more than text inside. If we need to unmount, we will have to change that behaviour.
Since React 19.2, <Activity> solves exactly that. It preserves state and the DOM, but runs the effects’ cleanup while the content is hidden and recreates them when it is shown again:
import { Activity } from "react";
function PanelContent({ children }: { children: ReactNode }) {
const { open, contentId } = usePanelContext();
return (
<Activity mode={open ? "visible" : "hidden"}>
<div id={contentId}>{children}</div>
</Activity>
);
}
The div stays in the DOM (React hides it with display: none), so aria-controls still points to an element that exists.
This is a design decision, not a free improvement. If some content needs its effects to keep running while the panel is closed (for example, a background sync), it will stop working. For a panel that displays content, pausing the effects is usually the right call. But it is worth documenting for whoever uses the component.
In the previous example there was a div with the class panel-heading between the root and the trigger. It does not need to receive or forward open: the context reaches the consumer even if there are intermediate elements. That is where we really are avoiding prop drilling.
Consumers re-render when the context value changes. In this panel we do not need to optimise it, so it is best to leave it as is; if performance problems appear, we will need to review that value and what each piece consumes.
Designing a clean component API
Before building all the pieces, we write two or three usages: a simple one, one with additional content and one with a different layout. It is much easier to discover there that the API is awkward than once it is spread across several production screens.
Names help a lot. With Trigger I expect an interaction, and with Content, the associated content. If I come across a Panel.Manager, I will probably have to open its implementation to find out what it does.
Next, we need to decide what the consumer can change. Helper text next to the trigger fits this composition; a link inside the button does not, because we would be nesting interactive controls. The consumer will also want to pass className, disabled or accessibility attributes, although some, such as aria-expanded, must remain tied to the panel’s state.
Events deserve a separate decision. If we accept onClick, does it run in addition to opening? Can it cancel the opening with preventDefault()? Further down we will implement one of those options. What we must avoid is the answer depending on the order in which someone placed a spread.
There is no need to settle the whole API on day one. I would, however, document its current constraints alongside the examples: one content element per root, the trigger inside the provider and no interactive control nested inside the button.
Controlled and uncontrolled Compound Components
For now the panel is uncontrolled: it stores its state internally and receives its initial value through defaultOpen.
<Panel defaultOpen>
<Panel.Trigger>Details</Panel.Trigger>
<Panel.Content>
<p>Additional order information.</p>
</Panel.Content>
</Panel>
If we change defaultOpen after mount, we are not issuing an instruction to open it. For that we need to control the state from outside, for example when a screen wants to open the panel after an action or coordinate it with other elements:
const [detailsOpen, setDetailsOpen] = useState(false);
<Panel
open={detailsOpen}
onOpenChange={setDetailsOpen}
>
<Panel.Trigger>Details</Panel.Trigger>
<Panel.Content>
<p>Additional order information.</p>
</Panel.Content>
</Panel>
The parent provides open and the panel requests changes through onOpenChange. To support both modes of use, we replace the types and implementation of PanelRoot:
type PanelRootProps = {
children: ReactNode;
} & (
| {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultOpen?: never; }
| {
open?: never;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
);
function PanelRoot(props: PanelRootProps) {
const [internalOpen, setInternalOpen] = useState(
props.defaultOpen ?? false
);
const contentId = useId();
const isControlled = props.open !== undefined;
const open = props.open ?? internalOpen;
function setOpen(nextOpen: boolean) {
if (!isControlled) {
setInternalOpen(nextOpen);
}
props.onOpenChange?.(nextOpen);
}
return (
<PanelContext.Provider
value={{ open, setOpen, contentId }}
>
<div className="panel">{props.children}</div>
</PanelContext.Provider>
);
}
The subcomponents do not need to change. They keep reading open and calling setOpen, regardless of where the value is stored.
In controlled mode, if the parent receives the callback but does not update open, the panel stays as it was. The callback requests the change; the parent is the one who applies it. For the same reason, it is better to avoid continuously copying open into internal state via an effect: we would be keeping another copy of a value the parent already controls.
I would also keep the chosen mode for the entire lifetime of the instance. Switching from controlled to uncontrolled, or vice versa, forces you to decide which state should win at that moment.
Type-safe Compound Components with TypeScript
The union type above forces us to pass onOpenChange when we provide open and prevents combining open with defaultOpen. These are combinations we can rule out before running the application.
What remains is to extend the trigger so it accepts button props. We will use the native types, reserving the attributes the panel needs for coordination:
import type { ComponentProps } from "react";
type PanelTriggerProps = Omit<
ComponentProps<"button">,
| "type"
| "aria-expanded"
| "aria-controls"
| "dangerouslySetInnerHTML"
> & {
children: ReactNode;
};
function PanelTrigger({
children,
onClick,
...props
}: PanelTriggerProps) {
const { open, setOpen, contentId } = usePanelContext();
return (
<button
{...props}
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) {
setOpen(!open);
}
}}
>
{children}
</button>
);
}
With this version we can pass disabled, className and other common attributes. The consumer’s onClick runs first and, if it calls preventDefault(), the opening is cancelled. The coordination attributes are placed after …props, so the panel keeps control over them.
In React 19, ref is just another prop of function components. By typing with ComponentProps<« button »>, the ref goes into …props and reaches the <button> without forwardRef or any changes to the implementation:
const triggerRef = useRef<HTMLButtonElement>(null);
<Panel.Trigger ref={triggerRef}>Details</Panel.Trigger>
This is useful, for example, to return focus to the trigger after an action.
If the component also has to work in React 18, the ref does not arrive as a prop and you need to wrap PanelTrigger with forwardRef. In React 19 forwardRef still works, but it is no longer needed and is planned to be deprecated.
There are constraints these types do not check. children: ReactNode allows compositions without Panel.Content, with several content elements or with one button inside another. Nor does it check that Panel.Trigger has a provider above it; the hook’s error takes care of that.
To validate the behaviour, test keyboard opening, the button attributes, controlled mode and that two panels keep independent states.
When Compound Components work well
I find them useful when the composition changes between screens but the behaviour stays the same. The panel can contain text, a form or several actions and still open the same way. In tabs, the pieces share the selection; in a menu, the relationship between the trigger and its options.
In a library used by several teams, this lets the base handle interactions and relationships, leaving consumers to adapt the content. A menu or tabs will require more work than the panel in this article: keyboard navigation, focus management and their specific semantics. That work is still part of the implementation.
It is time to consider the change when you see that every new screen asks for another position prop, another renderer or an exception to place content. At that point I would test whether that need is better expressed by letting the consumer place a piece directly.
When you should not use them
A notice that always has the same format can stay as it is:
<Notice
variant="warning"
message="Your session is about to expire."
/>
Having to write Notice.Root, Notice.Icon, Notice.Body and Notice.Message every time would be considerably more cumbersome for such a simple case. There is no need to turn that component into a compound API just for the sake of using the pattern.
For showing and hiding information, you should also look at details and summary. They can save us a good chunk of code. The panel in this article is useful for studying the implementation; in an application, you would need to weigh what you need that the browser does not already handle.
There are also interfaces where configuration is more convenient. If the server delivers a list of fields or actions, it probably makes more sense to build the interface from that data than to force yourself to express each case through manual composition.
And I would keep the context scoped to the component. Open state, selection or focus are states its pieces may need to share. Authentication, permissions, requests and data spanning several pages belong to a broader discussion about application architecture.
Compound Components vs. configuration props
We can combine both approaches without any problem. Props are still useful for data and bounded options such as size, disabled, open or a visual variant. Composition lets us decide what content appears and where:
<Panel defaultOpen>
<Panel.Trigger disabled={isLoading}>
Order information
</Panel.Trigger>
<Panel.Content>
<OrderSummary order={order} />
</Panel.Content>
</Panel>
disabled configures the trigger and order provides the data for the summary. What we no longer need is a collection of flags to describe the whole structure.
We can even offer a more opinionated version built on top of the original pieces:
function HelpPanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<Panel>
<Panel.Trigger>{title}</Panel.Trigger>
<Panel.Content>{children}</Panel.Content>
</Panel>
);
}
If almost every screen uses that layout, HelpPanel saves you from repeating it. When one needs something different, it can fall back on Panel.Trigger and Panel.Content. I find it a practical combination that gives the team a simple option without losing access to the pieces.
Conclusions
The sensible approach is to start with a regular component and watch which changes come in. If they are well-defined, limited values or variants, I would keep adding props. If they keep changing the structure, it is better to try a compound API with those use cases in front of you.
To check it, read the JSX of the next real use case. If it is easy to see what will be displayed and how it works, the API is helping. If the team needs more explanation than with the previous component, we need to review what can be simplified.
References
- React: useContext. How providers, consumers and context updates work.
- React: Sharing State Between Components. State ownership, and controlled and uncontrolled components.
- W3C WAI: Disclosure Pattern. Interaction and accessible attributes for showing and hiding content.



