The art of reusable React components feat. TWC
As React developers we are always compelled to build reusable React components. But this endavour is not always easy. It takes some effort to keep the React components in our codebases reusable and elegant.
TWC is a pretty cool library by the amazing OSS developer Greg Bergé that makes it simple to build reusable React components that are powered by Tailwind.
In this article I will first discuss the elements of a reusable component by building a Button
component. And then we'll see how TWC simplifies our implementation of the component by a significant margin while delighting us with a pretty cool DX.
The concepts discussed here are practically implemented in the fanstastic OSS library shadcn/ui. You check out my previous blog about it to get a deeper understanding.
Discussion
Building reusable React components is a standard practice in well structured React codebases. They are an effective method to express the common UI elements in a given design system as code. But implementing them with good enough quality should be performed carefully. This is because we need to make sure the reusable components we build are well encapsulated and independent of the context they are used in. Additionally they should be easily configurable via props to invoke different variants or behaviors that they might implement.
TailwindCSS and CSS modules are great ways to build React components with encapsulated styles. But for the context of this article I would be working with the Tailwind ecosystem but you can takeaway the core concepts to CSS module based approaches as well.
Developing a reusable React component might seem as simple as returning some JSX with a set of predefined styles. But is that really the case ? Let's explore this idea by trying to build a very flexible and reusable Button
component.
A fairly simple starting point to build a Button
can be as follows.
(Don't mind the tailwind making it bulky)
const Button = ({text}:{text:string}) => {
return (
<button
className="inline-flex items-center justify-center
whitespace-nowrap rounded-md
text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground hover:bg-primary/90
h-10 px-4 py-2">
{text}
</button>
);
};
export default Button;
/*
Note:
Here we are reffering colors that are exposed via the tailwind config as primary, primary-foreground etc...
*/
This seems like a reusable Button
. But what if we want to pass in something other than a string as the Button
content. In that case I could do...
const Button = ({children}:{children:React.ReactNode}) => {
return (
<button
className="inline-flex items-center justify-center
whitespace-nowrap rounded-md
text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground hover:bg-primary/90
h-10 px-4 py-2">
{children}
</button>
);
};
export default Button;
Now I'm exposing that we can pass any ReactNode as the button content. Now we have the flexibility to pass anything as the button content as we have on the native button
.
But we have a glaring problem in our current implementation. A button in the web platform contains a set of properties that is unique to a button. This includes important properties such as the type
, disabled
and aria-
attributes. In the way we have implemented currently, all that context is lost for the consumer of the component. They just see a component that takes in some children and render a component that looks like a button. But the details about other button specific properties are lost.
In order to fix it we can do something like this,
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>{};
const Button = ({...props }:ButtonProps) => {
return (
<button
{...props}
className="inline-flex items-center justify-center
whitespace-nowrap rounded-md
text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground hover:bg-primary/90
h-10 px-4 py-2"/>
);
};
export default Button;
Now we have a more interesting component. Whenever the Button
component is used, the consumer can see all the props that can be passed into a native button element. This is a more ergonomic experience. The Button
component looks and behaves like if it's actually a native Button
with some default styles.
But there's something we missed along the way. For scenarios such as tracking focus states of the button we want access the underlying DOM element of the button via a ref
. This is a given when we are dealing with a native button But in this scenario that is not available on our Button
element.
React provides us the forwardRef
primitive to handle this situation. Let's see how our component would look like now with ref forwarding implemented,
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ ...props }, ref) => {
return (
<button
{...props}
ref={ref}
className="inline-flex items-center justify-center
whitespace-nowrap rounded-md
text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground hover:bg-primary/90
h-10 px-4 py-2"/>
);
});
export default Button;
Additionally, we need to expose the className prop to the consumer of this component to add additional styles if needed. In order to support this behavior we can use a utility class management library like clsx
to append the additional classNames on to the base styles. Another benefit of the usage of clsx
is now we can apply these additional classNames conditionally as well.
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className,...props }, ref) => {
return (
<button
{...props}
ref={ref}
className={clsx("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2", className)}
/>
);
});
export default Button;
At this point we have a Button
component that is truly reusable. But the amount of code required to implement it has been somewhat verbose.
This is exactly where TWC comes into play. TWC internally handles the all the aforementioned concerns and their implementations and exposes minimal API surface.
Let's look at a TWCified implementation of our Button
component. This generates a reusable Button
component identical to our last implementation.
import { twc } from "react-twc";
const Button = twc.button`inline-flex items-center justify-center
whitespace-nowrap rounded-md
text-sm font-medium
ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
bg-primary text-primary-foreground hover:bg-primary/90
h-10 px-4 py-2`;
With twc you can also pass unstyled components as an argument and then annotate the styles. This allows for applying twc to generate reusable components out of headless ui components from other libraries like radix.
Here's an example of styling an hovercard from radix
import * as HoverCard from "@radix-ui/react-hover-card";
import { twc } from "react-twc";
const HoverCardContent = twc(
HoverCard.Content,
)``;
This setup we have is good enough for many simple use cases. But currently we are supporting only a single look and feel of the components using the styles passed via the style annotation. Sometimes we need to extend the behavior of our reusable components to support different variants of the same component in most web applications. In the context of a button, there can be variants like secondary
, destructive
, outline
, link
etc...
Therefore we need to utilize a variant management solution like cva
to add variant support for our reusable React components. Let's see how our Button
looks like after integrating cva
.
import { twc, TwcComponentProps } from "react-twc";
import { cva } from "class-variance-authority";
const button = cva("font-semibold border border-blue-500 rounded", {
variants: {
$intent: {
primary: "bg-blue-500 text-white",
secondary: "bg-white text-gray-800",
},
},
defaultVariants: {
$intent: "primary",
},
});
type ButtonProps = TwcComponentProps<"button"> & VariantProps<typeof button>;
const Button = twc.button<ButtonProps>(({ $intent }) => button({ $intent }));
Finally! We have a great reusable React component that also handles variants. Notice the use of the $intent
prop. In TWC we call it a Transient Prop
which means that it is not passed into the underlying button component, but its used to compute upper level details like the appropriate styles given a value for the variant.
Footnote
- A huge thanks to the amazing @shadcn for building shadcn/ui that is a treasure trove to learn about reusable React components.
- Shoutout to the amazing OSS developer Greg Bergé for building TWC that enables any dev to build reusable React componets at the speed of thought.