Photo from unsplash: jeremy-bishop-QUwLZNchflk-unsplash

How to Create Fully Reusable React Components

Creating a component is fairly easy, but doing them correctly is a different story.

Theodorus Clarence

Theodorus Clarence

December 14, 2023 — Last updated April 21, 2024

––– views

9 min read

0 likes

Introduction

Creating a React component is fairly easy, just a function like this.

export default function Card() {
  return <div>card</div>;
}

Then you can call them using JSX like <Card />, and it’s done.

However, to do them correctly is the reason that I write this blog. I’ve been wanting to write this for quite some time.

Doing them correctly may come naturally to developers who have been writing React for quite a while, but not for beginners. This is something that I learned the hard way over experience, nobody really taught me this. I want you who are currently reading to get them quickly.

The Common Flaw

If you look at the Card component that I created above, it has one crucial flaw: it’s not fully reusable.

But wait, it is reusable?! I can use them multiple times in different files.

Well yes, you can.

// order page
<Card />;
 
// product list page
<div>
  {products.map((product) => (
    <Card />
  ))}
</div>;

But they are not fully reusable.

A fully reusable component is what I call a component that is enjoyable to use. While the flawed ones frustrate you every time you need to customize them. It’s something that actually helps you code quickly by being fully reusable.

First, let's see how the flawed component looks like.

Flawed Component in Action

Let’s say we have a layout like this

normal-layout-three

We can easily achieve them using this code:

I use Tailwind CSS for this demo, but the concept applies to all solutions.

export default function ProductListPage() {
  return (
    <div className='grid-cols-3'>
      {products.map((product) => (
        <Card key={product.id} product={product} />
      ))}
    </div>
  );
}
 
function Card({ product }: { product: Product }) {
  return <div className='border p-1'>{product.title}</div>;
}

So we have a three-column grid, and we map all of the cards inside that grid. Inside the card we have a product props that renders the title. Pretty simple right?

Here’s how it went haywire.

Customization in Flawed Components

layout-with-featured

One day your lovely designer has requests:

  • Make the first card span over 2 columns (it’s for featured products)
  • When we click a product with a title containing ‘yay’, I want them to shoot out confetti by calling confetti().

Well, you could create two brand new components called FeatureCard and ConfettiCard, but it’s counter-productive. Everything inside is totally the same, except one uses two columns, and one shoots confetti.

Usually, when have this kind of situation, we rely upon custom props for each condition. But it goes downhill pretty quickly as the requirements grow.

interface CardProps {
  product: Product;
  isFeatured?: boolean;
  shootsConfetti?: boolean;
 
  isFeaturedThreeColumns?: boolean;
  isFeaturedButMakeItPop?: boolean;
  // 20 other props that your designer needs
}
 
function Card({ product, ...props }: CardProps) {
  return (
    <div
      // clsx is a simple library to combine string together
      className={clsx([
        'border p-1',
        props.isFeatured && 'col-span-2',
        props.isFeaturedThreeColumns && 'col-span-3',
        props.isFeaturedButMakeItPop && 'bg-pink-500',
      ])}
      onClick={() => {
        if (props.shootsConfetti) {
          confetti();
        }
      }}
    >
      {product.title}
    </div>
  );
}

I agree that using custom props sometimes is the best solution, but we can totally solve this problem if we can directly pass className and onClick directly into the component.

Fully Reusable Component as A Solution

So what we basically need is to have all props that are in a div, that concludes className, onClick, onHover, title, aria-label, style, about, id, onMouseEnter, onMouseLeave—yeah, you got my point.

It’s A LOT.

Don’t worry. We have a type for that, may I introduce React.ComponentPropsWithoutRef<'div'>. So instead of adding each and every component props, we can use this helpful type.

React.ComponentPropsWithoutRef also applies to any element you’re using: <'input'>, <'button'>, anything!

interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
  product: Product;
}
 
// first we grab the rest of the props using rest parameter,
function Card({ product, ...rest }: CardProps) {
  // then spread them back to our div
  return <div {...rest}>{product.title}</div>;
}

For those of you who are not familiar with ..., it actually represents 2 distinct operators: Rest Parameters & Spread Operators

  1. Rest Parameters (line 6) is used to get all of the properties from objects when destructuring
  2. Spread Operators (line 8) is used to spread all of the properties back to the object.

The best thing is we now get autocomplete! Woohoo!

autocomplete

With the updated component, we can finally make our designer happy with this implementation

export default function ProductListPage() {
  return (
    <div className='grid-cols-3'>
      {products.map((product, i) => (
        <Card
          key={product.id}
          product={product}
          className={clsx([i === 0 && 'col-span-2'])}
          onClick={() => {
            if (product.title.contains('yay')) confetti();
          }}
        />
      ))}
    </div>
  );
}

We can add the two-columns feature by adding a custom className, and the confetti feature by adding an onClick directly in the Card component.

Important Note about Fully Reusable Component

While it is a good practice to spread all of the props, do note that not all component needs to be fully reusable.

For example, a ProductList component that renders a list of products, probably only needs to be customizable in the className props. You can use Pick for that, and take only the className props.

interface ProductListProps
  extends Pick<React.ComponentPropsWithoutRef<'div'>, 'className'> {
  product: Product;
}
 
// No need to spread since we only need className
export default function ProductList({ className, products }: ProductListProps) {
  return (
    <div className={cn(['grid-cols-3', className])}>
      {products.map((product, i) => (
        <Card key={product.id} product={product} />
      ))}
    </div>
  );
}

We can just pick what we need from React.ComponentPropsWithoutRef<'div'> and use them in our component.

Some components that might need to be fully reusable are:

  • Reusable basic components like Button, Input, Select, Textarea, etc.
  • Reusable UI components like Modal, Popover, Tooltip, etc.

Do emphasize on the word reusable here. Make sure you're using this 'fully reusable component' concept to a reusable component.

Using Interface vs Type

Based on Matt Pocock's blog, interface Props extends .. {} is slightly faster than type Props = .. & {}.

Use interface extends.

Common Pitfalls & Solutions

By using fully reusable components, there are some pitfalls that you might encounter. I compiled some of them along with the solutions that I came up with.

Class Name Conflict

If you’re using Tailwind CSS, sometimes merging classes will cause a conflict.

function Card({
  product,
  // Don't forget to take className out of the rest parameter
  className,
  ...rest
}: CardProps) {
  return (
    <div className={clsx(['mt-4', className])} {...rest}>
      {product.title}
    </div>
  );
}
 
<Card className='mt-12' />;

We merge the className using clsx function, so any class that we pass outside of the component will be reflected in the final code.

However, in the rendered code we will have two different margin-top classes.

<div class='mt-12 mt-4'>...</div>

Which is not good. We can use tailwind-merge library to solve that.

tailwind-merge's function will return the latest value in the parameter. So it will prioritize our mt-12 over the mt-4. Basically what we need.

import { twMerge } from 'tailwind-merge';
 
twMerge('mt-4 bg-red hover:bg-dark-red', 'mt-12 bg-[#B91C1C]');
// → 'mt-12 bg-[#B91C1C] hover:bg-dark-red'

I usually create a wrapper with clsx like this

/src/lib/utils.ts
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
/** Merge classes with tailwind-merge with clsx full feature */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

So we now can merge conflicts and compose classes neatly.

You can safely customize your component now!

interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
  product: Product;
}
 
function Card({ product, className, ...rest }: CardProps) {
  return (
    <div className={cn(['mt-4', className])} {...rest}>
      {product.title}
    </div>
  );
}

Multiple Class Names

When you start to have more items inside the component, it can be quite confusing as to how to pass a className to a specific element.

Let’s say our Card component has a title, description, and image. We already use className props for the wrapper div. How can we customize the title class?

interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
  product: Product;
}
function Card({ product, className, ...rest }: CardProps) {
  return (
    <div className={cn('mt-4', className)} {...rest}>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <img src={product.image} />
    </div>
  );
}
 
// How to access h1, p, and img?
<Card className='' />;

In my experience, I always use the normal className for the outermost element (wrapper). The solution is to create another object for a specific element that I might need.

interface CardProps extends React.ComponentPropsWithoutRef<'div'> {
  product: Product;
  classNames?: {
    title?: string;
    description?: string;
    image?: string;
  };
}
function Card({ product, className, classNames, ...rest }: CardProps) {
  return (
    <div className={clsx('mt-4', className)} {...rest}>
      <h1 className={cn(classNames?.title)}>{product.title}</h1>
      <p className={cn(classNames?.description)}>{product.description}</p>
      <img className={cn(classNames?.image)} src={product.image} />
    </div>
  );
}
 
<Card classNames={{ title: 'text-red-500', image: 'aspect-square' }} />;

Here I created a classNames object with title, description, and image property. Then we can use them to merge the class in the respective element.

If you're using Tailwind CSS IntelliSense, another pro tip is to add this to your VSCode settings.

.vscode/settings.json
{ "tailwindCSS.classAttributes": ["class", "className", "classNames"] }

Adding this will enable Tailwind CSS IntelliSense to autocomplete in the classNames object.

Components With Ref

You might notice that the type name is ComponentProps_Without_Ref, yes there is another type called ComponentProps_With_Ref.

This is a needed case if you’re also forwarding ref to your component. I won’t explain in detail about ref forwarding, maybe in the next post (comment if you’d like me to write about it).

Simply, ref forwarding is needed when you want to access the ref value of your component. Usually external library like Radix does.

You can add the type like this.

type ButtonProps = React.ComponentPropsWithRef<'button'>;
 
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, children, ...rest }, ref) => {
    return (
      <button ref={ref} className={cn(className)} {...rest}>
        {children}
      </button>
    );
  }
);
 
export default Button;

Only then you can access the ref.

function Page() {
  const buttonRef = React.useRef<HTMLButtonElement>(null);
 
  React.useEffect(() => {
    buttonRef.current?.focus();
  }, []);
 
  return (
    <div>
      <Button type='submit' disabled ref={buttonRef}>
        Submit
      </Button>
    </div>
  );
}

See how I easily add type and disabled props because we’re using a fully reusable component? *chef’s kiss*

Conclusion

Please use fully reusable components. Your teammates and your future-self will thank you.

Share this article onx

Enjoying this post?

Don't miss out 😉. Get an email whenever I post, no spam.

Subscribe Now

New Blog Post

20m ago

Mastering Gradient Borders in CSS 🌈✨

A talk is happening

2h ago

Sharing My 2023 Retrospective

New Blog Post

4h ago

and many more!