React: Build your own composable, headless components
Tags: react
Published: 2024-01-23 04:24:51

Some time ago (maybe a year or two), in some podcast (maybe from [Kent C. Dodds](https://twitter.com/kentcdodds)) 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](https://reach.tech/).

Fast forward to a week ago, I cloned the [Reach UI](https://github.com/reach/reach-ui) and [Radix UI](https://github.com/radix-ui/primitives) 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](https://reach.tech/accordion). 

In this blog series, I will reverse-engineer [Reach UI Accordion](https://www.npmjs.com/package/@reach/accordion) (mostly) and [Raxix UI Accordion](https://www.npmjs.com/package/@radix-ui/react-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` and `keyboard navigations`

<strong>Disclaimer:</strong> 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:
```jsx
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.

```diff
- 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](https://twitter.com/kentcdodds)

Some html examples:
```html
<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:
```jsx
<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](https://react.dev/reference/react/Children) + [React.cloneElement](https://react.dev/reference/react/cloneElement) or the [Context](https://react.dev/reference/react/createContext) 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](https://github.com/HariBhandari07/headless-accordion) and get started from the [initial commit](https://github.com/HariBhandari07/headless-accordion/commit/c26ad5ddecb697bf7dcc4f171ea1e11ac92354b3)

File: `Accordion.tsx`
```jsx
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](https://github.com/HariBhandari07/headless-accordion/commit/93875c6e51b3e4c063cd2cf017ddaf002785bfb6)

Here, I have:
- Created Accordion element components: `Accordion`, `AccordionItem`, `AccordionButton`, and `AccordionPanel`. These components will be used to compose the `Accordion`
- 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](https://react.dev/reference/react/forwardRef).
- `as` props representing an HTML element or a React component that will tell the Accordion what element to render. The default value of `as` used is `div` and for `AccordionButton` a `button`.
- `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:
```jsx
  <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](https://react.dev/reference/react/createContext).

```jsx
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](https://github.com/HariBhandari07/headless-accordion/commit/93875c6e51b3e4c063cd2cf017ddaf002785bfb6)

Here,
- I have created two contexts `AccordionContext`, and `AccordionItemContext`, along with their respective `useContext` hooks: `useAccordionContext`, `useAccordionItemContext`
- `AccordionContext` will be used for global accordion states like keeping track of `open/closed` panels, and the corresponding updater function. While `AccordionItemContext` 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](https://github.com/radix-ui/primitives/blob/c31c97274ff357aea99afe6c01c1c8c58b6356e0/packages/react/context/src/createContext.tsx), [Reach UI](https://github.com/reach/reach-ui/blob/43f450db7bcb25a743121fe31355f2294065a049/packages/utils/src/context.tsx)