Maximizing Reusability: Compound Component Pattern and Headless UI
Chris created a Dropdown menu as a shared component for the team.
At first, it was simple. It just needed to show a list when a button was pressed.
<Dropdown items={['A', 'B']} />But then, requirements started pouring in.
Chris kept adding Props like showSearch, iconPosition, and customButton, eventually creating a monster component with over 20 props.
// โ Prop Explosion
<Dropdown
items={items}
showSearch={true}
searchPlaceholder="Search..."
icon="arrow"
position="bottom-right"
onSelect={handleSelect}
buttonColor="blue"
// ... It never ends
/>Such "Configuration-based" components are rigid.
Today, we will learn how to secure true reusability through the Compound Component Pattern, which involves assembling parts like the HTML <select> tag, and the Headless UI concept, which completely separates style from logic.
1. Wisdom from HTML
Let's look at the HTML <select> tag.
<select>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>We don't write <select options={['1', '2']} />.
Instead, two components, <select> and <option>, work together. <select> manages the state (selected value), and <option> handles the UI. This is the prototype of the Compound Component Pattern.
2. Implementing Compound Components
To implement this pattern in React, we need the Context API. This is because the parent component needs to share state implicitly with its children.
Step 1: Create Context
// SelectContext.tsx
import { createContext, useContext, useState } from 'react';
type SelectContextType = {
isOpen: boolean;
toggle: () => void;
selected: string;
select: (value: string) => void;
};
const SelectContext = createContext<SelectContextType | null>(null);
// Custom hook for safe usage
export const useSelectContext = () => {
const context = useContext(SelectContext);
if (!context) throw new Error("Select.* components must be used within Select.");
return context;
};Step 2: Assemble Parent and Child Components
// Select.tsx
export function Select({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState("");
const value = {
isOpen,
toggle: () => setIsOpen(!isOpen),
selected,
select: (val: string) => {
setSelected(val);
setIsOpen(false);
}
};
return (
<SelectContext.Provider value={value}>
<div className="select-root">{children}</div>
</SelectContext.Provider>
);
}
// Child Components (Sub Components)
Select.Trigger = function Trigger({ children }: { children: React.ReactNode }) {
const { toggle, selected } = useSelectContext();
return <button onClick={toggle}>{selected || children}</button>;
};
Select.Menu = function Menu({ children }: { children: React.ReactNode }) {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return <ul className="select-menu">{children}</ul>;
};
Select.Option = function Option({ value, children }: { value: string, children: React.ReactNode }) {
const { select } = useSelectContext();
return <li onClick={() => select(value)}>{children}</li>;
};Step 3: Usage
Now Chris has escaped Prop Hell. He can freely place the elements he wants, where he wants them.
// โ
Readable and Flexible
<Select>
<Select.Trigger>Open Menu</Select.Trigger>
<Select.Menu>
<div className="search-box">You can even put a search bar here</div>
<Select.Option value="apple">Apple ๐</Select.Option>
<Select.Option value="banana">Banana ๐</Select.Option>
</Select.Menu>
</Select>3. Headless UI: Logic Without Style
The structure has become flexible thanks to Compound Components, but if styles (e.g., className="select-menu") are still hardcoded inside, it's difficult to use in other design systems.
Headless UI goes one step further.
"I'll provide the functionality (logic), you apply the style (UI)."
Representative libraries include Radix UI, Headless UI (Tailwind), and React Aria.
Conceptual Code of a Headless Component
// useSelect.ts (Separate logic into a Hook)
function useSelect(items) {
const [isOpen, setIsOpen] = useState(false);
// ... Complex logic like keyboard navigation, focus management, ARIA attributes ...
return {
isOpen,
triggerProps: {
'aria-expanded': isOpen,
onClick: toggle
},
menuProps: {
role: 'listbox'
}
};
}Developers can take this hook and apply designs using whatever they like, whether it's styled-components or Tailwind CSS. This is the pinnacle of reusability.
4. When to Use Which Pattern?
| Pattern | Characteristics | Recommended Situation |
| Monolithic | Everything is one chunk. Controlled via Props. | Simple atomic components like buttons, inputs. |
| Compound | Parent-Child cooperation. Flexible structure. | UIs with complex internal structures like Dropdowns, Accordions, Tabs, Modals. |
| Headless | No style, logic only. | When building shared libraries where design requirements vary wildly or Accessibility (A11y) is critical. |
Key Takeaways
Now that we've made the structure flexible, it's time to separate business logic from UI code. If useEffect and return <div> are mixed in one component, it's hard to read.
Let's look at the Custom Hook Pattern, the modern interpretation of the "Presentational & Container" pattern, to separate them cleanly.
Continuing in: โSeparation of Concerns: Separating View and Business Logic (Custom Hook)โ