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.

TWC library banner

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 Profile Headshot Of Manupa Samarawickrama

Manupa Samarawickrama

HomeAboutBlogLet's Talk