SUHAS KASHYAP

Designing a good React component: Part 1

March 7, 2023 (1y 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!