DEV Community

Cover image for React: Build your own composable, headless components
Hari Bhandari
Hari Bhandari

Posted on

React: Build your own composable, headless components

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 and keyboard 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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

CheckPoint: 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.
  • 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:

  <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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

CheckPoint: e26e87e8407a08949898f356d7d9c274c97e46a1

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, Reach UI

Top comments (3)

Collapse
 
_ndeyefatoudiop profile image
Ndeye Fatou Diop

Very niche post ! Looking forward to the rest !

Collapse
 
haribhandari profile image
Hari Bhandari

@_ndeyefatoudiop the remaining part of this series is already published. Part 2: dev.to/haribhandari/uncontrolled-c...

Collapse
 
_ndeyefatoudiop profile image
Ndeye Fatou Diop

Very nice 👌🏿