Designing a good React component: Part 1
7 Mar 2023Or 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
- React context API
- 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!