Suhas Kashyap

Designing a good React component: Compount Component Pattern

March 7, 2023 (2y ago)


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:

const items: MenuItem[] = [
  getItem('Option 1', '1', <PieChartOutlined />),
  getItem('Option 2', '2', <DesktopOutlined />),
  getItem('Option 3', '3', <ContainerOutlined />),

  getItem('Navigation One', 'sub1', <MailOutlined />, [
    getItem('Option 5', '5'),
    getItem('Option 6', '6'),
    getItem('Option 7', '7'),
    getItem('Option 8', '8'),
  ]),

  getItem('Navigation Two', 'sub2', <AppstoreOutlined />, [
    getItem('Option 9', '9'),
    getItem('Option 10', '10'),

    getItem('Submenu', 'sub3', null, [getItem('Option 11', '11'), getItem('Option 12', '12')]),
  ]),
];

<Menu
  defaultSelectedKeys={['1']}
  defaultOpenKeys={['sub1']}
  mode="inline"
  theme="dark"
  inlineCollapsed={collapsed}
  items={items}
/>;

vs
Headless UI:

<Menu>
  <Menu.Button>More</Menu.Button>
  <Menu.Items>
    <Menu.Item>
      {({ active }) => (
        <a className={`${active && "bg-blue-500"}`} href="/account-settings">
          Account settings
        </a>
      )}
    </Menu.Item>
    <Menu.Item>
      {({ active }) => (
        <a className={`${active && "bg-blue-500"}`} href="/account-settings">
          Documentation
        </a>
      )}
    </Menu.Item>
    <Menu.Item disabled>
      <span className="opacity-75">Invite a friend (coming soon!)</span>
    </Menu.Item>
  </Menu.Items>
</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:

const CarouselRoot = ({
  children,
  loop = false,
  freeWidth = false,
  orientation = 'horizontal',
  slidesToScroll = 1,
}: {
  children: ReactNode;
  loop?: boolean;
  freeWidth?: boolean;
  orientation?: 'vertical' | 'horizontal';
  slidesToScroll?: number;
}) => {
  // ref that created the "embla" carousel

  const [canScrollPrev, setCanScrollPrev] = useState(false);
  const [canScrollNext, setCanScrollNext] = useState(true);

  // control functions
  const movePrevious = useCallback(() => {
    embla?.scrollPrev();
  }, [embla]);
  const moveNext = useCallback(() => {
    embla?.scrollNext();
  }, [embla]);

  return (
    <CarouselContext.Provider
      value={{
        embla,
        canScrollPrev,
        canScrollNext,
        moveNext,
        movePrevious,
        emblaRefElement,
      }}
    >
      {/* nothing rendered! */}
      {children}
    </CarouselContext.Provider>
  );
};

and the context being:

const CarouselContext = createContext<{
  embla: EmblaCarouselType | undefined | null;
  emblaRefElement: any; // lol
  canScrollPrev: boolean;
  canScrollNext: boolean;
  movePrevious: Function;
  moveNext: Function;
} // and initialisation

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

const ScrollNextBtn = ({ className }: { className?: string }) => {
  const context = useContext(CarouselContext);
  // ^ that

  const { canScrollNext, moveNext } = context;
  // fetch things that were set at root

  if (context.embla === null) {
    throw  Error('<ScrollNextBtn /> is missing a parent <Carousel /> component.');
  }
  // ^ this way, I can't randomly have this sib-component anywhere
  // it HAS to be inside root component

  return (
    // rendering logic
  );
};

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

const Carousel = Object.assign(CarouselRoot, {
  ScrollPrevBtn,
  ScrollNextBtn,
  Item,
  ItemList,
});

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

<Carousel slidesToScroll={1} loop={true}>
  <Carousel.ItemList>
    <Carousel.Item className="flex-[0_0_100%]">
      <Image />
    </Carousel.Item>

    <Carousel.Item className="flex-[0_0_100%]">
      <Text />
    </Carousel.Item>

    <Carousel.Item className="flex-[0_0_100%]">
      <Div />
    </Carousel.Item>
  </Carousel.ItemList>

  {/* control buttons */}
  <div>
    <Carousel.ScrollPrevBtn className="h-8 w-8" />
    <Carousel.ScrollNextBtn className="ml-3 h-8 w-8" />
  </div>
</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!