In this blog, let’s learn an important React Design Pattern — Compound Components. We will understand what this pattern is all about by making a reusable Accordion Component.
What are Compound Components?
Compound Component is a powerful React Design Pattern to make reusable components, where the idea is to have two or more components work together to accomplish a useful task. Typically we will have one parent component and at least one child component. We combine these components together to provide a more flexible component API. These components internally will have its own state which the components within the parent component can access and decide on how to render. This gives a nice way to express relationships between components.
<select>
and <option>
API in HTML is an example of compound components. You see, individually <select>
and <option>
by themselves are not very useful, but when combined together in a logical way, it becomes a powerful component.
Now that we theoretically know what a compound component is, let’s now solidify our understanding by implementing an Accordion component.
<>
<Accordion>
<AccordionItem>
<AccordionButton>
<div>Click Me !</div>
</AccordionButton>
<AccordionPanel>
<p>This is panel 1</p>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<div>Click Me !</div>
</AccordionButton>
<AccordionPanel>
<p>This is panel 2</p>
</AccordionPanel>
</AccordionItem>
</Accordion>
</>;
This component has Accordion
as a parent component. This component will have AccordionItem
as its child component, each AccordionItem
is a content wrapper for the Accordion
content we would want to have. AccordionButton
is a component that will handle the click event to toggle the Accordion from open to close and vice-verca. AccordionPanel
component is where all the actual content would go and would be toggled open and close by the AccordionButton
.
Now, we have an idea of how the Accordion API should look like, let’s start the implementation with the basic structure. One thing which is common among these components is the ability to accept children.
import { createContext, PropsWithChildren } from "react";
type AccordionProps = {};
const Accordion = ({}: PropsWithChildren<AccordionProps>) => {
return <div>{children}</div>;
};
type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
return <div className="accordion-item accordion-item-style">{children}</div>;
};
type AccordionButtonProps = {};
const AccordionButton = ({
children,
}: PropsWithChildren<AccordionButtonProps>) => {
return <div className="accordion-button">{children}</div>;
};
type AccordionPanelProps = {};
const AccordionPanel = ({
children,
}: PropsWithChildren<AccordionPanelProps>) => {
return <div>{children}</div>;
};
export { Accordion, AccordionItem, AccordionButton, AccordionPanel };
import {
Accordion,
AccordionButton,
AccordionItem,
AccordionPanel,
} from "./Accordion";
import "./styles.css";
export default function App() {
return (
<div>
<Accordion>
<AccordionItem>
<AccordionButton>
<div>Click Me!</div>
</AccordionButton>
<AccordionPanel>
<p>This is accordion panel 1</p>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<div>Click Me!</div>
</AccordionButton>
<AccordionPanel>
<p>This is accordion panel 1</p>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
With this structure in place, the output will look like something below.
Let’s understand what features this Accordion component should have:
I want the
Accordion
component to have the ability to optionally keep item or items panel to be open by default.I want the
Accordion
component to have the ability to collapse all the items to be toggled to a closed state, or at any given time one accordion panel will be always open.I want the
Accordion
component to have the ability to have multiple accordion panels open or closed at a time, or at any given time only one panel will be open.
We can achieve these by declaring props for each of the above as below:
type AccordionIndex = number | number[];
type AccordionProps = {
// A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
defaultIndex?: AccordionIndex;
// Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
collapisble?: boolean;
// Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
multiple?: boolean;
};
const Accordion = ({
children,
defaultIndex,
collapisble = false,
multiple = false,
}: PropsWithChildren<AccordionProps>) => {
return <div>{children}</div>;
};
Notice the type for defaultIndex
is AccordionIndex
which is either a number
or number[]
. This is because, we can pass it based on whether we want to keep one panel open by default or more than one panel, in which case we can pass the indices as an array of numbers.
We need some state to be accessible by all child components that would keep track of index or indices of open accordion panels, let’s call this openPanels
. We also need a callback that would be invoked when we toggle any accordion panel.
Since we want this to be accessible within all child components, we can have a context that would store this and through the provider, React can make these accessible to all child components.
type AccordionIndex = number | number[];
type AccordionCtxt = {
openPanels: AccordionIndex;
onSelectPanel: (index: number) => void;
};
const AccordionContext = createContext<AccordionCtxt | undefined>(undefined);
type AccordionProps = {
// A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
defaultIndex?: AccordionIndex;
// Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
collapsible?: boolean;
// Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
multiple?: boolean;
};
const getInitialOpenPanelState = (
multiple: boolean,
collapsible: boolean,
defaultIndex?: AccordionIndex
) => {
if (defaultIndex != null) {
if (multiple) {
return Array.isArray(defaultIndex) ? defaultIndex : [defaultIndex];
} else {
return Array.isArray(defaultIndex) ? defaultIndex[0] ?? 0 : defaultIndex!;
}
} else {
if (collapsible) {
return multiple ? [] : -1;
}
return multiple ? [0] : 0;
}
};
const Accordion = ({
children,
defaultIndex,
collapsible = false,
multiple = false,
}: PropsWithChildren<AccordionProps>) => {
const initialOpenPanelsVal = getInitialOpenPanelState(
multiple,
collapsible,
defaultIndex
);
const [openPanels, setOpenPanels] =
useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);
const onSelectPanel = useCallback(
(index: number) => {
setOpenPanels((prevOpenPanels) => {
if (multiple) {
prevOpenPanels = prevOpenPanels as number[];
if (prevOpenPanels.includes(index)) {
if (prevOpenPanels.length > 1 || collapsible) {
return prevOpenPanels.filter((i) => i !== index);
}
} else {
return [...prevOpenPanels, index].sort();
}
} else {
prevOpenPanels = prevOpenPanels as number;
return prevOpenPanels === index && collapsible ? -1 : index;
}
return prevOpenPanels;
});
},
[collapsible, multiple]
);
return (
<AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
<div>{children}</div>
</AccordionContext.Provider>
);
};
To make the Accordion
API really simple, we need to somehow track what elements are children of Accordion
component - here we want to only track AccordionItem
as its child or descendant. We could have another prop called as accordionData on the Accordion
and based on it we could render AccordionItem
with index as its prop that would track it, but we want to make it flexible and not dependent on any prop. It should handle it internally whenever AccordionItem
is added.
We can make this happen by registering the component as descendant of Accordion
and make the descendants accessible to all its child component by using context. We extract all this logic into a hooks.
type Descendant = {
element: HTMLElement;
index: number;
};
const useDescendants = () => {
const [descendants, setDescendants] = useState<Descendant[]>([]);
const registerDescendant = useCallback((element: HTMLElement) => {
setDescendants((previousDescendants) => {
if (
!previousDescendants.find(
(descendant) => descendant.element === element
)
) {
const updatedDescendats = [
...previousDescendants,
{ element, index: previousDescendants.length },
];
return updatedDescendats.map((descendants, index) => {
return {
...descendants,
index,
};
});
}
return previousDescendants;
});
}, []);
const unregisterDescendant = useCallback((element: HTMLElement) => {
setDescendants((previousDescendants) =>
previousDescendants.filter((descendant) => descendant.element !== element)
);
}, []);
return { descendants, registerDescendant, unregisterDescendant };
};
const useDescendant = (
ref: RefObject<HTMLElement>,
context: ReturnType<typeof useDescendants>
) => {
const { descendants, registerDescendant, unregisterDescendant } = context;
useLayoutEffect(() => {
if (ref.current) {
registerDescendant(ref.current);
return () => unregisterDescendant(ref.current);
}
}, []);
const index = descendants.findIndex(
(descendant) => descendant.element === ref.current
);
return index;
};
const DescendantContext = createContext<ReturnType<
typeof useDescendants
> | null>(null);
We then have to wrap the component with Descendent Provider to be accessible by the useContext
hook.
type AccordionProps = {
// A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
defaultIndex?: AccordionIndex;
// Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
collapsible?: boolean;
// Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
multiple?: boolean;
};
const Accordion = ({
children,
defaultIndex,
collapsible = false,
multiple = false,
}: PropsWithChildren<AccordionProps>) => {
const initialOpenPanelsVal = getInitialOpenPanelState(
multiple,
collapsible,
defaultIndex
);
const [openPanels, setOpenPanels] =
useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);
const onSelectPanel = useCallback(
(index: number) => {
setOpenPanels((prevOpenPanels) => {
if (multiple) {
prevOpenPanels = prevOpenPanels as number[];
if (prevOpenPanels.includes(index)) {
if (prevOpenPanels.length > 1 || collapsible) {
return prevOpenPanels.filter((i) => i !== index);
}
} else {
return [...prevOpenPanels, index].sort();
}
} else {
prevOpenPanels = prevOpenPanels as number;
return prevOpenPanels === index && collapsible ? -1 : index;
}
return prevOpenPanels;
});
},
[collapsible, multiple]
);
const descsendantContext = useDescendants();
return (
<DescendantContext.Provider value={descsendantContext}>
<AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
<div>{children}</div>
</AccordionContext.Provider>
</DescendantContext.Provider>
);
};
useDescendants
returns the context that we want to make it available to the child components and useDescendant
returns an index of the registered descendant. Since we want to track AccordionItem
as descendant of Accordion, we will use this hook in AccordionItem component.
Let’s understand AccordionItem
implementation. Before that we will declare the states of the panels as enum
.
enum AccordionStates {
Open = "OPEN",
Collapsed = "COLLAPSED",
}
Each AccordionItem
will be either of the above AccordionStates
, we need to store this information along with the index of AccordionItem
and make it accessible to its child, And we will do this with the help of context in React.
enum AccordionStates {
Open = "OPEN",
Collapsed = "COLLAPSED",
}
type AccordionItemContext = {
index: number;
state: AccordionStates;
};
const AccordionItemContextValue = createContext<
AccordionItemContext | undefined
>(undefined);
type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
return <AccordionItemContextValue.Provider value={{index:0,state:AccordionStates.Open}}>
<div className="accordion-item accordion-item-style">{children}</div>
</AccordionItemContextValue.Provider>;
};
We will create few more hooks to access the context value of all the contexts we have created till now.
const useAccordionContext = () => {
const ctxt = useContext(AccordionContext);
if (!ctxt) {
throw new Error("");
}
return ctxt;
};
const useDescendantsContext = () => {
const ctxt = useContext(DescendantContext);
if (!ctxt) {
throw new Error("");
}
return ctxt;
};
const useAccordionItemContext = () => {
const ctxt = useContext(AccordionItemContextValue);
if (!ctxt) {
throw new Error();
}
return ctxt;
};
And use it when needed.
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
const ref = useRef<HTMLDivElement>(null);
const ctxt = useDescendantsContext();
const index = useDescendant(ref as RefObject<HTMLDivElement>, ctxt);
const { openPanels } = useAccordionContext();
const state =
(Array.isArray(openPanels)
? openPanels.includes(index) && AccordionStates.Open
: openPanels === index && AccordionStates.Open) ||
AccordionStates.Collapsed;
return (
<AccordionItemContextValue.Provider value={{ index, state }}>
<div ref={ref} className="accordion-item accordion-item-style">
{children}
</div>
</AccordionItemContextValue.Provider>
);
};
That was about AccordionItem
component.
AccordionButton
is now really simple, we have already made the logic, we now just have to use the logic and add the onClick
event to use this logic.
type AccordionButtonProps = {};
const AccordionButton = ({
children,
}: PropsWithChildren<AccordionButtonProps>) => {
const { onSelectPanel } = useAccordionContext();
const { index } = useAccordionItemContext();
return (
<div onClick={() => onSelectPanel(index)} className="accordion-button">
{children}
</div>
);
};
And the AccordionPanel
as well, we just have to extract the state from AccordionItem
context and based on the state, toggle the panel.
type AccordionPanelProps = {};
const AccordionPanel = ({
children,
}: PropsWithChildren<AccordionPanelProps>) => {
const { state } = useAccordionItemContext();
return (
<div hidden={state !== AccordionStates.Open} className="accordian-panel">
{children}
</div>
);
};
With this we have completed the full implementation of Accordion component. The full code is as below:
import {
createContext,
PropsWithChildren,
RefObject,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
type Descendant = {
element: HTMLElement;
index: number;
};
const useDescendants = () => {
const [descendants, setDescendants] = useState<Descendant[]>([]);
const registerDescendant = useCallback((element: HTMLElement) => {
setDescendants((previousDescendants) => {
if (
!previousDescendants.find(
(descendant) => descendant.element === element
)
) {
const updatedDescendats = [
...previousDescendants,
{ element, index: previousDescendants.length },
];
return updatedDescendats.map((descendants, index) => {
return {
...descendants,
index,
};
});
}
return previousDescendants;
});
}, []);
const unregisterDescendant = useCallback((element: HTMLElement) => {
setDescendants((previousDescendants) =>
previousDescendants.filter((descendant) => descendant.element !== element)
);
}, []);
return { descendants, registerDescendant, unregisterDescendant };
};
const useDescendant = (
ref: RefObject<HTMLElement>,
context: ReturnType<typeof useDescendants>
) => {
const { descendants, registerDescendant, unregisterDescendant } = context;
useLayoutEffect(() => {
if (ref.current) {
registerDescendant(ref.current);
return () => unregisterDescendant(ref.current);
}
}, []);
const index = descendants.findIndex(
(descendant) => descendant.element === ref.current
);
return index;
};
const DescendantContext = createContext<ReturnType<
typeof useDescendants
> | null>(null);
const useDescendantsContext = () => {
const ctxt = useContext(DescendantContext);
if (!ctxt) {
throw new Error("");
}
return ctxt;
};
const getInitialOpenPanelState = (
multiple: boolean,
collapsible: boolean,
defaultIndex?: AccordionIndex
) => {
if (defaultIndex != null) {
if (multiple) {
return Array.isArray(defaultIndex) ? defaultIndex : [defaultIndex];
} else {
return Array.isArray(defaultIndex) ? defaultIndex[0] ?? 0 : defaultIndex!;
}
} else {
if (collapsible) {
return multiple ? [] : -1;
}
return multiple ? [0] : 0;
}
};
type AccordionIndex = number | number[];
type AccordionCtxt = {
openPanels: AccordionIndex;
onSelectPanel: (index: number) => void;
};
const AccordionContext = createContext<AccordionCtxt | undefined>(undefined);
const useAccordionContext = () => {
const ctxt = useContext(AccordionContext);
if (!ctxt) {
throw new Error("");
}
return ctxt;
};
type AccordionProps = {
// A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
defaultIndex?: AccordionIndex;
// Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
collapsible?: boolean;
// Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
multiple?: boolean;
};
const Accordion = ({
children,
defaultIndex,
collapsible = false,
multiple = false,
}: PropsWithChildren<AccordionProps>) => {
const initialOpenPanelsVal = getInitialOpenPanelState(
multiple,
collapsible,
defaultIndex
);
const [openPanels, setOpenPanels] =
useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);
const onSelectPanel = useCallback(
(index: number) => {
setOpenPanels((prevOpenPanels) => {
if (multiple) {
prevOpenPanels = prevOpenPanels as number[];
if (prevOpenPanels.includes(index)) {
if (prevOpenPanels.length > 1 || collapsible) {
return prevOpenPanels.filter((i) => i !== index);
}
} else {
return [...prevOpenPanels, index].sort();
}
} else {
prevOpenPanels = prevOpenPanels as number;
return prevOpenPanels === index && collapsible ? -1 : index;
}
return prevOpenPanels;
});
},
[collapsible, multiple]
);
const descsendantContext = useDescendants();
return (
<DescendantContext.Provider value={descsendantContext}>
<AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
<div>{children}</div>
</AccordionContext.Provider>
</DescendantContext.Provider>
);
};
enum AccordionStates {
Open = "OPEN",
Collapsed = "COLLAPSED",
}
type AccordionItemContext = {
index: number;
state: AccordionStates;
};
const AccordionItemContextValue = createContext<
AccordionItemContext | undefined
>(undefined);
const useAccordionItemContext = () => {
const ctxt = useContext(AccordionItemContextValue);
if (!ctxt) {
throw new Error();
}
return ctxt;
};
type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
const ref = useRef<HTMLDivElement>(null);
const ctxt = useDescendantsContext();
const index = useDescendant(ref as RefObject<HTMLDivElement>, ctxt);
const { openPanels } = useAccordionContext();
const state =
(Array.isArray(openPanels)
? openPanels.includes(index) && AccordionStates.Open
: openPanels === index && AccordionStates.Open) ||
AccordionStates.Collapsed;
return (
<AccordionItemContextValue.Provider value={{ index, state }}>
<div ref={ref} className="accordion-item accordion-item-style">
{children}
</div>
</AccordionItemContextValue.Provider>
);
};
type AccordionButtonProps = {};
const AccordionButton = ({
children,
}: PropsWithChildren<AccordionButtonProps>) => {
const { onSelectPanel } = useAccordionContext();
const { index } = useAccordionItemContext();
return (
<div onClick={() => onSelectPanel(index)} className="accordion-button">
{children}
</div>
);
};
type AccordionPanelProps = {};
const AccordionPanel = ({
children,
}: PropsWithChildren<AccordionPanelProps>) => {
const { state } = useAccordionItemContext();
return (
<div hidden={state !== AccordionStates.Open} className="accordian-panel">
{children}
</div>
);
};
export { Accordion, AccordionItem, AccordionButton, AccordionPanel };
Benefits
Separation of Concerns and Encapsulation
Encapsulated Logic: The Accordion component encapsulates the shared state and logic (like which panel is open) and passes this context to its subcomponents (AccordionItem, AccordionButton, AccordionPanel). This means each subcomponent only focuses on its own rendering and behavior.
Clear Responsibilities: Each component (e.g., the button or the panel) has a single responsibility. This separation makes the code easier to understand, maintain, and extend.
Flexible Composition and Reusability
Composable Building Blocks: Consumers of your Accordion component can compose and nest the subcomponents as needed. They can customize layout or behavior by rearranging these building blocks without worrying about breaking the internal state management.
Reusability: Since the components are decoupled, they can be reused in different contexts or even extracted to be used in other similar patterns.
State Sharing Without Prop Drilling
Context API: Using React’s Context API to share state (like which panels are open and how to toggle them) eliminates the need to pass props through every level of the component tree. This makes the component tree cleaner and the code easier to manage.
Centralized State Management: All the accordion’s behavior is managed in one place, reducing the risk of inconsistent states between components.
Customization and Extensibility
Flexible APIs: Consumers can extend or override parts of the Accordion without needing to modify the core logic. For example, if someone wants to use a different styling or add additional behaviors to the button, they can create a new component that still hooks into the Accordion’s context.
Theming and Styling: Since the components are separate, you can more easily apply different styles or themes to each part of the accordion.
Improved Testing and Debugging
Isolated Testing: Each compound component can be tested in isolation, allowing for more straightforward unit tests. For example, you can test the behavior of an AccordionButton separately from the overall Accordion state management.
Clear Data Flow: The use of context and clearly defined component boundaries simplifies the debugging process since it’s easier to trace how state and events flow through the component hierarchy.
Found this blog helpful? Give it a like and share it with your fellow developers to spread the knowledge!