Don’t Make Black Box React Components

When building reusable React components, it’s tempting to make black boxes. That would be something like this:

<BrandedCard 
 title="My Card" 
 dismissIcon={<CloseIcon />}
 topBadge="New!"
 imgUrl="https://mysite.com/image.jpg"
>
 Card content goes here
</BrandedCard>

At first glance, this looks fine.

You have a specific use-case for this component, you got a design and it is used in a single place, and it works perfectly for that. You built it in a way that’s fast for the developer to implement, and they can’t screw it up. What is the problem?

The problem is that there is markup that you can’t customize or change hidden inside this component.

  • I have no access to the title or badge markup
  • I can change the icon, but I can’t change where it’s displayed
  • I cannot access the image markup

When you built this component, it was for one use-case, so none of that mattered.

Then something changes.

Another team needs this component, but they need the image at the bottom instead of the top. No problem! you can just add an imageBottom prop:

<BrandedCard imageBottom={true} ... />

Now your own team needs another badge on the bottom, so you add a bottomBadge prop.

<BrandedCard imageBottom={true}
 bottomBadge="On Sale!" ... />

You keep adding props for every change, and eventually you have prop soup and an unusable monster component.

But wait, there’s more…

Duplicate Code

What if you need a similar component for a product catalog. It’s a BrandedCard, but it needs a lot of new markup.

You can use about half of your current code, so you copy/paste that into a ProductCard component. Now you do the same dance with adding props every time a change is needed, and now you have 2 monster components that are difficult to maintain.

The Solution

The way to avoid this mess is to make every part of your component available to the developer. You can do this with React dot notation:

<BrandedCard>

 <BrandedCard.Image src="..." />
 <BrandedCard.Header>
  <BrandedCard.Icon />
  <BrandedCard.Title>My title</BrandedCard.Title>
 </BrandedCard.Header>

 <BrandedCard.Content>
  Content goes here...
 </BrandedCard.Content>

</BrandedCard>

Now if a team needs to move the badge, title, or image, they change the markup. If you need to add something, just add it to the markup. You make no changes to the internal code or props.

This is definitely more verbose than our black box component, and this may raise concerns about usability for some team members.

One way to mitigate this issue is to provide great documentation with copy/paste recipes. Want to make this a product card? Here’s the markup, just copy/paste.

The other option is to compose a simpler component. Make all the changes needed, add new props as necessary.

export function BrandedProductCard(props) {
 return ( 
  <BrandedCard>
   <div className={props.customClass}>some changes here...</div>
   <BrandedCard.Image...
  // ...you get the idea

Now you can have a simple <BrandedProductCard> without all the dot notation markup. But, if a change needs to be made, you can still do that without changing the <BrandedCard> code.

The big advantage here is that you are not making internal changes or adding props to accommodate every use case. That means your code remains maintainable and reusable no matter what happens in the future.

What about styling and logic? I’d recommend allowing a className and/or style prop on each internal component, as well as a classNames API on the root component. This has worked really well for us. I wrote about that approach in this post.

As for logic, extract as much as you can to custom hooks, so that they can be reusable as well.

Leave a comment

Your email address will not be published. Required fields are marked *