Some time ago (maybe a year or two), in some podcast (maybe from Kent C. Dodds) I heard something along the lines of "Rich UI codebase has the best resource for learning react composition..."
.
What kind of name, "Rich Ui"? Is this supposed to be only used by rich people? 🤔 With a quick Google search (rich ui react), I found Reach UI.
Fast forward to a week ago, I cloned the Reach UI and Radix UI codebase and started exploring. Large codebases are always difficult to comprehend. With some digging around and reverse engineering, I was able to create the first component listed in the Reach UI
docs, the Accordion.
In this blog series, I will reverse-engineer Reach UI Accordion (mostly) and Raxix UI Accordion, and build a headless, composable Accordion from scratch while documenting the progress. Although we are building an Accordion, the primary purpose of the blog is to explore how open-source composable components are built. The techniques and patterns you learn here will be useful when creating React components like Tabs
, Menu
, etc. and hence the title Build your own composable, headless component
Along the process we will learn something about:
-
component composition
, -
compound components
, -
descendants pattern
(don't know if it's called a pattern), -
controlled and uncontrolled components
, -
composing refs
, -
composing event handlers
, -
accessibility
andkeyboard navigations
Disclaimer: This tutorial is not intended for beginners in React. If you are not comfortable with context
, states(useState
, useRef
), and effects (useEffect
) you may find it challenging to follow along. Please note that the code presented here may not be suitable for production use.
This series can be beneficial if you are:
- Trying to create your component library
- Curious to learn how composable components are built in the OSS libraries like reach ui, radix-ui
God Component
In your job, when creating a component (accordion in this case), you may have done something like this:
const accordionData = [
{ id: 1, headingText: 'Heading 1', panel: 'Panel1 Content' },
{ id: 2, headingText: 'Heading 2', panel: 'Panel2 Content' },
{ id: 3, headingText: 'Heading 3', panel: 'Panel3 Content' },
]
const SomeComponent = () => {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div>
<Accordion
data={accordionData}
activeIndex={activeIndex}
onChange={setActiveIndex}
/>
</div>
)
}
function Accordion({ data, activeIndex, onChange }) {
return (
<div>
{data.map((item, idx) => (
<div key={item.id}>
<button onClick={() => onChange(idx)}>
{item.headingText}
</button>
<div hidden={activeIndex !== idx}>{item.panel}</div>
</div>
))}
</div>
)
}
Suppose the requirement changes, and now you need to add support for an icon in the accordion button/ heading, some different styling, or some other requirements.
- function Accordion({ data, activeIndex, onChange }) {
+ function Accordion({ data, activeIndex, onChange, displaySomething, doNothing }) {
+ if (doNothing) return
return (
<div>
{data.map((item, idx) => (
<div key={item.id}>
<button onClick={() => onChange(idx)}>
{item.headingText}
+ {item.icon? (
+ <span className='someClassName'>{item.icon}</span>
+ ) : null}
</button>
- <div hidden={activeIndex !== idx}>{item.panel}</div>
+ <div hidden={activeIndex !== idx}>
+ {item.panel}
+ {displaySomething}
+ </div>
</div>
))}
</div>
)
}
With every changing requirement, you will need to refactor your "God Component" to meet the needs of the consuming components. And if you are building a component to be used in multiple projects with different requirements, or an OSS component library this kind of "God Component" approach will most likely fail. This is just a contrived example but it highlights the issues that can arise from using this approach.
Compound Components
So, what's a compound component? "Compound components" is a pattern for creating a meaningful component by putting together smaller nonsensical components.
Think of compound components like the
<select>
and<option>
elements in HTML. Apart they don't do too much, but together they allow you to create the complete experience. The way they do this is by sharing an implicit state between the components. Compound components allow you to create and use components that share this state implicitly.— Kent C. Dodds
Some html examples:
<select>
<option>Option1</option>
<option>Option2</option>
</select>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<table>
<tr>
<thead>....
<tbody>
<tr>...
<td>..
...
</table>
Using compound components, the accordion that we built above in the God component section would look something like this:
<Accordion>
<AccordionItem>
<AccordionButton>Heading 1</AccordionButton>
<AccordionPanel>
Panel 1
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>Heading 2</AccordionButton>
<AccordionPanel>
Panel 2
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>Heading 3</AccordionButton>
<AccordionPanel>
Panel 3
</AccordionPanel>
</AccordionItem>
</Accordion>
Here, using Accordion
alone, or AccordionButton
alone, won't work. To get a fully functional Accordion you would need to compose Accordion
, AccordionItem
, AccordionButton
, AccordionPanel
together as shown above.
Compound components can be created using either the combination of React.Children + React.cloneElement or the Context API. But React.Children + React.cloneElement
combo is not as flexible, and its API is deprecated. So, we will be using the Context API
.
Getting Started
Set up a react app using vite, or you can clone this repo and get started from the initial commit
File: Accordion.tsx
const Accordion = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion="">
{children}
</Comp>
);
});
const AccordionItem = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionItemProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
{children}
</Comp>
);
});
const AccordionButton = forwardRef(function (
{ children, as: Comp = "button", ...props }: AccordionButtonProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-button="">
{children}
</Comp>
);
});
const AccordionPanel = forwardRef(function (
{ children, as: Comp = "div", ...props }: AccordionPanelProps,
forwardedRef
) {
return (
<Comp {...props} ref={forwardedRef} data-hb-accordion-panel="">
{children}
</Comp>
);
});
CheckPoint: 93875c6e51b3e4c063cd2cf017ddaf002785bfb6
Here, I have:
- Created Accordion element components:
Accordion
,AccordionItem
,AccordionButton
, andAccordionPanel
. These components will be used to compose theAccordion
- Wrapped each Accordion Element Component in forwardRef to expose the DOM node of the respective Accordion Element. For more information regarding forward ref, check here.
-
as
props representing an HTML element or a React component that will tell the Accordion what element to render. The default value ofas
used isdiv
and forAccordionButton
abutton
. -
data-*
attribute is used for each Accordion element component. This can be used as a CSS selector to provide styling or when writing tests for the component.
Now, the composed accordion will look something like this:
<Accordion>
<Accordion.Item>
<Accordion.Button>Button 1</Accordion.Button>
<Accordion.Panel>Panel 1</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Button>Button 2</Accordion.Button>
<Accordion.Panel>Panel 2</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Button>Button 3</Accordion.Button>
<Accordion.Panel>Panel 3</Accordion.Panel>
</Accordion.Item>
</Accordion>
To keep track of open/closed accordion items and manage state updates between these Accordion elements, we will use the Context API.
const AccordionContext = createContext({});
const AccordionItemContext = createContext({});
const useAccordionContext = () => {
const context = useContext(AccordionContext);
if (!context) {
throw Error("useAccordionContext must be used within Accordion.");
}
return context;
};
const useAccordionItemContext = () => {
const context = useContext(AccordionItemContext);
if (!context) {
throw Error("useAccordionItemContext must be used within AccordionItem.");
}
return context;
};
.......
const Accordion = forwardRef(function (
.....
return (
<AccordionContext.Provider value={{}}>
.....
</AccordionContext.Provider>
);
});
const AccordionItem = forwardRef(function (
.....
return (
<AccordionItemContext.Provider value={{}}>
....
</AccordionItemContext.Provider>
);
});
CheckPoint: e26e87e8407a08949898f356d7d9c274c97e46a1
Here,
- I have created two contexts
AccordionContext
, andAccordionItemContext
, along with their respectiveuseContext
hooks:useAccordionContext
,useAccordionItemContext
-
AccordionContext
will be used for global accordion states like keeping track ofopen/closed
panels, and the corresponding updater function. WhileAccordionItemContext
will be used for individual accordion item states like if an accordion item is disabled, item index, etc.
Reach UI and Radix UI use their context package (a wrapper on Context API) to create contexts for the components, but I won't be doing that here. You can check it out here: Radix UI, Reach UI
Top comments (3)
Very niche post ! Looking forward to the rest !
@_ndeyefatoudiop the remaining part of this series is already published. Part 2: dev.to/haribhandari/uncontrolled-c...
Very nice 👌🏿