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
- 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:
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!