The compound component pattern
A flexible component authoring pattern to give consumers a composition-based API
Compound components are components that share a common context to perform one or more given task(s) together. They work by exposing a declarative API leaving the consumer to compose them to achieve their goal. This is in contrast to components with an imperative API that usually requires more extensive configuration properties to handle their task internally.
What we're going to build
Which has the following API
import * as Accordion from './Accordion'
function AccordionDemo() {
return (
<Accordion.Root>
<Accordion.Header>
Header content
<Accordion.Icon />
</Accordion.Header>
<Accordion.Panel>Panel content</Accordion.Panel>
</Accordion.Root>
)
}
A shared context
To orchestrate the multiple components' behavior, they need a way to share information between each other.
Concept
Scoped information sharing can be implemented in multiple ways but the core idea is to:
- Share observable information between subscribers to that information
- This information is scoped to its required subscribers and can be manipulated via an API, either by the subscribers or other defined external setters.
React implementation
ReactJS proposes a native API to handle such use cases: the Context API.
In our case, we will use the context API to handle the accordion open/close state and make it available for subscriptions by others accordion components.
import { createContext, useContext, useState } from 'react'
import type { AccordionRootProps } from './Accordion.Root'
interface AccordionContextValues extends Omit<AccordionRootProps, 'children'> {
id: string
isOpen: boolean
onToggleChange: () => void
}
export const AccordionContext = createContext<AccordionContextValues | null>(null)
export const useAccordion = () => {
const context = useContext(AccordionContext)
if (!context) {
throw new Error('useAccordionContext must be used within a AccordionProvider')
}
return context
}
type AccordionProviderProps = AccordionRootProps & { id: string }
export const Provider = ({
children,
isOpen: isOpenProps = false,
onChange,
...props
}: AccordionProviderProps) => {
const [isOpen, setIsOpen] = useState(isOpenProps)
const onToggleChange = () => {
setIsOpen((isOpen) => {
onChange && onChange(!isOpen)
return !isOpen
})
}
return (
<AccordionContext.Provider value={{ ...props, isOpen, onToggleChange }}>
{children}
</AccordionContext.Provider>
)
}
We will then use it within our Accordion.Root
component that will wrap our other components with the purpose of making the shared
the information available for others and applying some container styles to the accordion.
import { ReactNode, useId, forwardRef } from 'react'
import type { ComponentProps } from 'react'
import clsx from 'clsx'
import { Provider } from './Accordion.Provider'
type WrapperDivProps = Omit<ComponentProps<'div'>, 'onChange'>
export interface AccordionRootProps extends WrapperDivProps {
children: ReactNode
isOpen?: boolean
onChange?: (isOpen: boolean) => void
}
export const Root = forwardRef<HTMLDivElement, AccordionRootProps>(function Root(
{ children, isOpen, onChange, className, ...props },
ref
) {
const id = useId()
return (
<Provider isOpen={isOpen} onChange={onChange} id={id}>
<div
ref={ref}
{...props}
className={clsx(
'rounded-2xl border border-solid border-gray-200 shadow-sm transition-shadow duration-300 hover:shadow-md',
className
)}
>
{children}
</div>
</Provider>
)
})
Small single-purpose components
With the shared state handled by our Accordion.Root
component, we need to make the other components that compose our Accordion.
With composition, flexibility, and extensivity in mind, we will make these components as simple to use as possible, with some basic accessibility in mind.
Header
Our header component is a styled button that triggers the open/close state of the accordion. As such, it's pretty simple, especially since the open/close logic is handled by the context provider.
import clsx from 'clsx'
import { ComponentProps, forwardRef } from 'react'
import { useAccordion } from './Accordion.Provider'
export type AccordionHeaderProps = ComponentProps<'button'>
export const Header = forwardRef<HTMLButtonElement, AccordionHeaderProps>(function Header(
{ children, className, ...props },
ref
) {
const { isOpen, onToggleChange, id } = useAccordion()
return (
<h3 className="w-full">
<button
ref={ref}
id={id}
aria-controls={`panel-${id}`}
aria-expanded={isOpen}
onClick={onToggleChange}
className={clsx(
'flex w-full flex-grow items-center justify-between rounded-t-2xl p-4 ',
isOpen && 'border-b border-gray-200',
!isOpen && 'rounded-b-2xl',
className
)}
{...props}
>
{children}
</button>
</h3>
)
})
Icon
Our icon component is a purely decorative one that serves as a visual indicator of the opening capacity of the accordion when in its closed state.
import { motion } from 'framer-motion'
import { ComponentProps, forwardRef } from 'react'
import { ChevronDownIcon } from '@heroicons/react/24/solid'
import clsx from 'clsx'
import { useAccordion } from './Accordion.Provider'
export type AccordionIconProps = Omit<
ComponentProps<'svg'>,
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag' | 'ref'
>
const MotionChevronDownIcon = motion(ChevronDownIcon)
export const Icon = forwardRef<SVGSVGElement, AccordionIconProps>(function (
{ className, ...props },
ref
) {
const { isOpen } = useAccordion()
return (
<MotionChevronDownIcon
animate={{ rotate: isOpen ? 90 : 0, transition: { duration: 0.3 } }}
ref={ref}
className={clsx('h-4 w-4 text-slate-800 dark:text-white', className)}
{...props}
/>
)
})
Panel
Our panel component is fairly simple as well. It contains the elements that we want to show/hide based on the open/closed state of our accordion. Therefore, in our implementation, it's just a subscriber to our shared state with an animated open/close behavior.
import { motion } from 'framer-motion'
import { ComponentProps, forwardRef } from 'react'
import clsx from 'clsx'
import { useAccordion } from './Accordion.Provider'
export type AccordionPanelProps = Omit<
ComponentProps<'div'>,
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag' | 'ref'
>
const variants = {
open: { opacity: 1, height: 'auto' },
closed: { opacity: 0, height: 0 },
}
export const Panel = forwardRef<HTMLDivElement, AccordionPanelProps>(function Panel(
{ children, className, ...props },
ref
) {
const { isOpen, id } = useAccordion()
return (
<motion.div
ref={ref}
id={`panel-${id}`}
initial={isOpen ? 'open' : 'closed'}
animate={isOpen ? 'open' : 'closed'}
variants={variants}
transition={{ duration: 0.3 }}
role="region"
aria-hidden={!isOpen}
className={clsx('overflow-hidden', className)}
{...props}
>
{children}
</motion.div>
)
})
Conclusion
With all these components built, our export contract will be simple as well:
// Accordion.tsx
export { Header } from './components/Accordion.Header'
export { Icon } from './components/Accordion.Icon'
export { Panel } from './components/Accordion.Panel'
export { Root } from './components/Accordion.Root'
// Consumer file
import * as Accordion from './Accordion'
So looking back at this authoring pattern, what are its PROs and CONs:
Pros
- Single responsibility components
- Flexible component usage
- Component extensibility
Cons
- Slightly harder to build for beginners
- The added flexibility and extensibility mean that the consumers can shoot themselves in the foot more easily
To conclude, this authoring pattern makes a lot of sense for a lot of UI components since product UI evolves quickly and the added flexibility and extensibility will avoid internalizing the product evolution over time.
But it's a not solution that makes sense for all UI components. Some specific UI components (Datepickers for example) require so much tight coupling between the inner-components that adding too much flexibility provides a greater risk of the consumers shooting themselves in the foot than properly handling their needs by extending the component.
From the same categories
How to build a datepicker from scratch
ReactAnimations2022-11-20
Building a nice datepicker with animations and gesture support
Immutable vs mutable, a recent use-case where purity and immutability caused performance issues
GeneralReact2022-05-01
Using a mutable approach may lead to enormous performance gains.