Created On Apr 07, 2025 With Tags React, Pattern
React give us the powerful capability to manage states within component, and encapsulating state is generally considered a best practice. However, in real-world applications, it is more than often that UI features require multiple interconnected pieces to function properly.
The traditional approach is to build a monolithic component that contains everything related to a feature, lifting shared state to the top level. The Compound Component pattern is a more elegant solution of handling states in complex components. The pattern seperates the concerns by breaking component into multiple pieces that communicate in the background to accomplish certain behavior. Many popular component library, such as Radix Primitive and Shadcn/ui, leverage Compound Component pattern extensively.
Let's start with a common UI component, an accordion, and examine how it's typically implemented. An accordion consists of:
Here's a minimal monolithic implementation:
accordion.tsx
accordion.module.css
import React from "react"import accordionStyle from './accordion.module.css'import { ChevronDown } from "./icon";export default function Accordion({ title, children }: { title?: string, children: React.ReactNode,}) { const [isCollapsed, setIsCollapsed] = React.useState(false); return <div data-collapsed={isCollapsed} className={accordionStyle.AccordionRoot}> <div className={accordionStyle.AccordionHeader} onClick={() => setIsCollapsed(collapsed => !collapsed)}> <h6>{title}</h6> <ChevronDown className={accordionStyle.AccordionTrigger} /> </div> <div className={accordionStyle.AccordionContent}> {children} </div> </div>}
The state management is quite straightforward, just a single isCollapsed
to control the expand/collapse behavior.
One key advantage of React component is reusability. However, the reusablity is meaningless without flexibility. Thus, in order to push the component to its full potential, let's customize it!
Considering following cases:
import React from "react"import accordionStyle from './accordion.module.css'import { ChevronDown } from "./icon";export default function Accordion({ title, children, defaultOpen = false, triggerIcon, titleStyle = {}, contentStyle = {}, onTrigger, isCollapsed: isCollapsedOverride, triggerOnLeft = false,}: { title?: string, children: React.ReactNode, defaultOpen?: boolean, triggerIcon?: React.ReactSVGElement, titleStyle?: React.CSSProperties, contentStyle?: React.CSSProperties, onTrigger?(isCollapsing: boolean): void, isCollapsed?: boolean, triggerOnLeft?: boolean,}) { const [isCollapsed, setIsCollapsed] = React.useState(defaultOpen); if (triggerIcon) { const originalProps = triggerIcon.props; triggerIcon = React.cloneElement<{ className: string }, SVGSVGElement>( triggerIcon, { ...originalProps, className: `${accordionStyle.AccordionTrigger} ${triggerIcon.props.className}` }); } else { triggerIcon = <ChevronDown className={accordionStyle.AccordionTrigger} /> as React.ReactSVGElement } return <div data-collapsed={isCollapsedOverride != undefined ? isCollapsedOverride : isCollapsed} className={accordionStyle.AccordionRoot}> <div className={accordionStyle.AccordionHeader} style={titleStyle} onClick={ isCollapsedOverride != undefined ? undefined : () => { if (onTrigger != undefined) onTrigger(!isCollapsed); setIsCollapsed(c => !c); } }> {triggerOnLeft && triggerIcon} <h6>{title}</h6> {!triggerOnLeft && triggerIcon} </div> <div className={accordionStyle.AccordionContent} style={contentStyle}> <div className={accordionStyle.AccordionContentWrapper}> {children} </div> </div> </div>}
When implementing the component in a monolithic pattern, exposing more arguments is a very common way to support more features. WHile this approach works, it introduces significant complexity indeed:
The core functionality of accordion component is simple: using the isCollapsed
state to connect the trigger and content container. The layout, styles, and custom hooks all comes later, while the monolithic implementation implies a lot more than that.
Putting pieces within same component is not the only solution of sharing states. Context API allows sharing states across different components by wrapping them inside a context.Provider
wrapper. This would help us to break our monolithic accordion into discrete, composable pieces.
import React from "react"const AccordionContext = React.createContext<{ isCollapsed: boolean, trigger(): void,}>({ isCollapsed: false, trigger: () => { }});function AccordionRoot({ children }: { children: React.ReactNode }) { const [isCollapsed, setIsCollapsed] = React.useState<boolean>(true); const trigger = () => setIsCollapsed(c => !c); return <AccordionContext.Provider value={{ isCollapsed: isCollapsed, trigger: trigger }}> {children} </AccordionContext.Provider>}const AccordionTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionTrigger(props, ref) { const { isCollapsed, trigger } = React.useContext(AccordionContext); return <div {...props} ref={ref} onClick={trigger} data-collapsed={isCollapsed} /> })const AccordionHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionHeader(props, ref) { const { isCollapsed } = React.useContext(AccordionContext); return <div {...props} ref={ref} data-collapsed={isCollapsed} /> })const AccordionContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionContent(props, ref) { const { isCollapsed } = React.useContext(AccordionContext); return <div {...props} ref={ref} data-collapsed={isCollapsed} /> })export { AccordionRoot as Root, AccordionTrigger as Trigger, AccordionHeader as Header, AccordionContent as Content,};
We end up with four components, the three visual component Trigger
, Header
,Content
as mentioned previously, and an Root
component which internally wrap children using context.Provider
component.
Adding styles inside or outside of the component will be another dicussion. For this blog, let's enhance these with some default styles and additional functionality.
accordion.tsx
accordion.module.css
import React from "react"import accordionStyle from './accordion.module.css'const AccordionContext = React.createContext<{ isCollapsed: boolean, trigger(): void,}>({ isCollapsed: false, trigger: () => { }});interface AccordionRootProps { defaultCollapsed?: boolean, isCollapsed?: boolean, // the Accordion will become a controlled if this is provided. onCollapsed?(): void, onUncollapsed?(): void,}const AccordionRoot = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & AccordionRootProps>( function AccordionRoot({ children, className, defaultCollapsed = false, isCollapsed: isCollapsedOverride, onCollapsed, onUncollapsed, ...other }, ref) { const [isCollapsed, setIsCollapsed] = React.useState<boolean>(defaultCollapsed); const onTrigger = () => { if (isCollapsed && onUncollapsed) onUncollapsed(); else if (!isCollapsed && onCollapsed) onCollapsed(); setIsCollapsed(c => !c) }; return <AccordionContext.Provider value={{ isCollapsed: isCollapsedOverride ?? isCollapsed, trigger: isCollapsedOverride == undefined ? onTrigger : () => { }, }}> <div className={className ?? accordionStyle.AccordionRoot} data-collapsed={isCollapsed} ref={ref} {...other}> {children} </div> </AccordionContext.Provider> })const AccordionTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionTrigger({ children, className, ...others }, ref) { const { trigger } = React.useContext(AccordionContext); return <div className={className ?? accordionStyle.AccordionTrigger} ref={ref} onClick={trigger} {...others}> {children} </div> })const AccordionHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionHeader({ className, ...others }, ref) { return <div ref={ref} className={className ?? accordionStyle.AccordionHeader} {...others} /> })const AccordionContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( function AccordionContent({ children, className, ...others }, ref) { return <div ref={ref} className={className ?? accordionStyle.AccordionContent} {...others}> {children} </div> })export { AccordionRoot as Root, AccordionTrigger as Trigger, AccordionHeader as Header, AccordionContent as Content,};
Now the Root
component accept properties related the isCollapsed
state, such as a isCollapsed
property to turn the component into a controlled version, and two optional callbacks onCollapsed
and onUncollapsed
.
All other pieces, Trigger
, Header
, Content
accept all sets of React.HTMLAttributes<HTMLDivElement>
.
For usage, instead of using a single Accordion
component with numerous porps, consumers compose UI from building blocks.
page.tsx
page.module.css
import * as Accordion from "@/components/accordion-compound-rich";import { ChevronDown } from "@/components/icon";import pageStyle from "./page.module.css";export default function Home() { return ( <Accordion.Root> <Accordion.Header> <h6>This is a Title</h6> <Accordion.Trigger> <ChevronDown className={pageStyle.AccordionRotateIcon} /> </Accordion.Trigger> </Accordion.Header> <Accordion.Content> Lorem ipsum dolor sit amet, summo dicant mnesarchum eum an, eu mea alii facilisis. Sed brute vocent suscipit ad, in cum dicant moderatius. Audiam copiosae liberavisse id eos, natum elitr iisque eu has. Est ut partem possim alienum, nec no malis singulis. In quem minimum pro, ne vero errem indoctum pro. Iisque scripta consectetuer at vis, ei has dicta simul deleniti, sea consul postulant torquatos at. </Accordion.Content> </Accordion.Root> );}
The power of compound component pattern becomes apparent when customization comes into play. And we apply all sets of properties to them just like treating native html element.
page.tsx
page.module.css
import * as Accordion from "@/components/accordion-compound-rich";import { ArrowInput, ArrowOutput } from "@/components/icon";import pageStyle from "./page.module.css";export default function Home() { return ( <Accordion.Root> <Accordion.Header> <div> <h6>This is a Title</h6> <p style={{ opacity: .5 }}>And customized Icon</p> </div> <Accordion.Trigger> <ArrowInput className={pageStyle.IconShowOnUncollapsed} /> <ArrowOutput className={pageStyle.IconShowOnCollapsed} /> </Accordion.Trigger> </Accordion.Header> <Accordion.Content> Lorem ipsum dolor sit amet, summo dicant mnesarchum eum an, eu mea alii facilisis. Sed brute vocent suscipit ad, in cum dicant moderatius. Audiam copiosae liberavisse id eos, natum elitr iisque eu has. Est ut partem possim alienum, nec no malis singulis. In quem minimum pro, ne vero errem indoctum pro. Iisque scripta consectetuer at vis, ei has dicta simul deleniti, sea consul postulant torquatos at. </Accordion.Content> </Accordion.Root> );}
page.tsx
page.module.css
import * as Accordion from "@/components/accordion-compound-rich";import { ArrowInput, ArrowOutput } from "@/components/icon";import pageStyle from "./page.module.css";export default function Home() { return ( <Accordion.Root> <Accordion.Header> <div> <h6>This is a Title</h6> <p style={{ opacity: .5 }}>And customized Icon</p> </div> <Accordion.Trigger> <ArrowInput className={pageStyle.IconShowOnUncollapsed} /> <ArrowOutput className={pageStyle.IconShowOnCollapsed} /> </Accordion.Trigger> </Accordion.Header> <Accordion.Content> Lorem ipsum dolor sit amet, summo dicant mnesarchum eum an, eu mea alii facilisis. Sed brute vocent suscipit ad, in cum dicant moderatius. Audiam copiosae liberavisse id eos, natum elitr iisque eu has. Est ut partem possim alienum, nec no malis singulis. In quem minimum pro, ne vero errem indoctum pro. Iisque scripta consectetuer at vis, ei has dicta simul deleniti, sea consul postulant torquatos at. </Accordion.Content> </Accordion.Root> );}
By breaking down the mono-component into small pieces, we seperate the concerns, each piece has a focused and simple interface, and only in charge of one thing. It provides more flexibility on the consumer side to customize each piece of the component, while still maintain the functionality.
However it does bring some challenges, and some potential solution:
useContext
hook to enforce the hierachy.asChild
property would allow the layer to become omre transparent.The compound component pattern represents a significant shift in how we build and consume UI components in React. While monolithic components are simpler to implement initially, they quickly become unwieldy when addressing real-world customization needs.
Compound components offer a more scalable and flexible alternative by breaking complex UI into logical, composable pieces.
As the React application or component library matures, transitioning from monolithic components to compound patterns might be a good choice. This approach may require slightly more code and introduce a small learning curve, but the gains in flexibility and maintainability are well worth the investment.