Designing a good React component: Part 1

7 Mar 2023

Or what I think is a good way to create one.

I attended a tech meetup hosted by GeekyAnts a few months ago. The speaker, Siddharth, who happens to be a design system engineer at Github talked about how they go about creating components over there.

This got me thinking about creating components beyond basic props and children. I started looking up sources of some OSS libraries - the first one being headless UI.

Headless UI - a good component library

Headless UI is created by the guys behind Tailwind CSS. Did I happen to mention how much I like Tailwind?

The idea is simple - what if there existed a component that had absolutely no styles whatsoever and only the functionality and a11y features? That's headless UI for you.

I liked the way their API is presented, you have the main component as the wrapper, then you have several sub-components that go inside the main component to build the individual building blocks of the main component.

That's when I realized, this is how you SHOULD be doing it.

How should I be doing the what now?

Simple example:

Take a look at Ant Design's menu component: ant.design/components/menu

Now look at headless ui's: headlessui.com/react/menu

What difference do you notice?
It's this part:

Ant:

1const items: MenuItem[] = [
2  getItem('Option 1', '1', <PieChartOutlined />),
3  getItem('Option 2', '2', <DesktopOutlined />),
4  getItem('Option 3', '3', <ContainerOutlined />),
5
6  getItem('Navigation One', 'sub1', <MailOutlined />, [
7    getItem('Option 5', '5'),
8    getItem('Option 6', '6'),
9    getItem('Option 7', '7'),
10    getItem('Option 8', '8'),
11  ]),
12
13  getItem('Navigation Two', 'sub2', <AppstoreOutlined />, [
14    getItem('Option 9', '9'),
15    getItem('Option 10', '10'),
16
17    getItem('Submenu', 'sub3', null, [getItem('Option 11', '11'), getItem('Option 12', '12')]),
18  ]),
19];
20
21<Menu
22  defaultSelectedKeys={['1']}
23  defaultOpenKeys={['sub1']}
24  mode="inline"
25  theme="dark"
26  inlineCollapsed={collapsed}
27  items={items}
28/>;

vs
Headless UI:

1<Menu>
2  <Menu.Button>More</Menu.Button>
3  <Menu.Items>
4    <Menu.Item>
5      {({ active }) => (
6        <a className={`${active && 'bg-blue-500'}`} href="/account-settings">
7          Account settings
8        </a>
9      )}
10    </Menu.Item>
11    <Menu.Item>
12      {({ active }) => (
13        <a className={`${active && 'bg-blue-500'}`} href="/account-settings">
14          Documentation
15        </a>
16      )}
17    </Menu.Item>
18    <Menu.Item disabled>
19      <span className="opacity-75">Invite a friend (coming soon!)</span>
20    </Menu.Item>
21  </Menu.Items>
22</Menu>

And what's the difference you say? The freedom! The control! The ability to modify component to my liking. If I want to add an image to my menu, in the case of Headless UI, I just create a Menu.Item component and throw in whatever the heck I want inside it!

Isn't this so much more elegant?

All props aren't thrown at top level component - only given to the specific sub-component that needs it.

This is called the "Compound component" React pattern.

Primer (Github's design system/component library) follows the same design as well - primer.style/react/CheckboxGroup. And so do a bunch of other UI Component libraries.

Show me how to do this

This works through 2 specific things

  1. React context API
  2. Object.assign

The "root" or top level component is only just a context Provider - it stores states refs and functions, but does not render anything. Its job is to make sure any sub-component written inside of this root gets access to the root's context.

If that wasn't clear, let me give an example of a carousel component that I had created at ProjectPro:

1const CarouselRoot = ({
2  children,
3  loop = false,
4  freeWidth = false,
5  orientation = 'horizontal',
6  slidesToScroll = 1,
7}: {
8  children: ReactNode;
9  loop?: boolean;
10  freeWidth?: boolean;
11  orientation?: 'vertical' | 'horizontal';
12  slidesToScroll?: number;
13}) => {
14  // ref that created the "embla" carousel
15
16  const [canScrollPrev, setCanScrollPrev] = useState(false);
17  const [canScrollNext, setCanScrollNext] = useState(true);
18
19  // control functions
20  const movePrevious = useCallback(() => {
21    embla?.scrollPrev();
22  }, [embla]);
23  const moveNext = useCallback(() => {
24    embla?.scrollNext();
25  }, [embla]);
26
27  return (
28    <CarouselContext.Provider
29      value={{
30        embla,
31        canScrollPrev,
32        canScrollNext,
33        moveNext,
34        movePrevious,
35        emblaRefElement,
36      }}
37    >
38      {/* nothing rendered! */}
39      {children}
40    </CarouselContext.Provider>
41  );
42};

and the context being:

1const CarouselContext = createContext<{
2  embla: EmblaCarouselType | undefined | null;
3  emblaRefElement: any; // lol
4  canScrollPrev: boolean;
5  canScrollNext: boolean;
6  movePrevious: Function;
7  moveNext: Function;
8} // and initialisation

Now, I could create my sub-components checking if the carousel context was present or not.

1const ScrollNextBtn = ({ className }: { className?: string }) => {
2  const context = useContext(CarouselContext);
3  // ^ that
4
5  const { canScrollNext, moveNext } = context;
6  // fetch things that were set at root
7
8  if (context.embla === null) {
9    throw  Error('<ScrollNextBtn /> is missing a parent <Carousel /> component.');
10  }
11  // ^ this way, I can't randomly have this sib-component anywhere
12  // it HAS to be inside root component
13
14  return (
15    // rendering logic
16  );
17};

And in the end, I can nicely package the whole shebang with Object.assign

1const Carousel = Object.assign(CarouselRoot, {
2  ScrollPrevBtn,
3  ScrollNextBtn,
4  Item,
5  ItemList,
6});

so that I can use this carousel like so with whatever item I want inside it:

1<Carousel slidesToScroll={1} loop={true}>
2  <Carousel.ItemList>
3    <Carousel.Item className="flex-[0_0_100%]">
4      <Image />
5    </Carousel.Item>
6
7    <Carousel.Item className="flex-[0_0_100%]">
8      <Text />
9    </Carousel.Item>
10
11    <Carousel.Item className="flex-[0_0_100%]">
12      <Div />
13    </Carousel.Item>
14  </Carousel.ItemList>
15
16  {/* control buttons */}
17  <div>
18    <Carousel.ScrollPrevBtn className="h-8 w-8" />
19    <Carousel.ScrollNextBtn className="ml-3 h-8 w-8" />
20  </div>
21</Carousel>

Further Reading

Read how Headless UI is written - github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/menu/menu.tsx

They've written some fantastic code!