How Containment in React Can Improve Your Code

How Containment in React Can Improve Your Code

Featured on Hashnode

Component composition is undoubtedly where React shines. Being able to freely compose larger views from smaller, flexible building blocks is great. But you have to be careful because it's easy to get it wrong (or at least suboptimal).

In this article, we'll discuss what is containment and how it can improve a lot of aspects of your code.

An example

Recently I noticed the following pattern used in our codebase.

import Icon from "./Icon";
import HamburgerButton from "./HamburgerButton";

function Header({
  title,
  shouldShowIcon,
  iconProps,
  shouldShowButton,
  buttonProps,
}) {
  return (
    <header>
      <h1>{title}</h1>
      <div>
        {shouldShowIcon && <Icon {...iconProps} />}
        {shouldShowButton && <HamburgerButton {...buttonProps} />}
      </div>
    </header>
  );
}

Using this component could look something like so.

<Header
  title="The Best Website"
  shouldShowIcon
  iconProps={{ size: "2em", icon: "star" }}
  shouldShowButton
  buttonProps={{
    onClick() {
      openMenu();
    },
  }}
/>

I'm specifically referring to how Icon and HamburgerButton components are rendered. There are at least a few problems with this approach.

Blurred responsibility

Which component is responsible for rendering the icon and the button? Header or its parent? You might say it's Header because it imports and uses the components. But then it's also its parent because it defines whether icon and button should be rendered and what props they should get.

The parent component delegates work to the child component that it could as well do itself, resulting in blurred responsibility.

Longer path to understanding the code

To understand what's being rendered I need to go through two components. But we can easily imagine that between the component that defines the criteria for rendering icon and button and the Header is half a dozen of other components, each passing the props down. This makes the code simply harder to trace.

Poor performance

Let's say our Header component has some internal state that all of the sudden changes. Naturally, the component re-renders. As do the icon and the button components inside, even though they don't necessarily have to. We got ourselves some unnecessary re-renders.

For icons and buttons, it's unlikely to cause problems, but for more expensive components it might negatively impact performance. Kent C. Dodds does a deep dive into why it happens in this blog post.

Hard to extend and reuse

Our app is successful, it grows and we need that header on another screen. But this time with a user avatar.

How do we do this will the current approach? We'd have to extend the API with two more props and make sure we explicitly disable rendering of the icon and button.

<Header
  title="New Screen"
  shouldShowIcon={false}
  shouldShowButton={false}
  shouldShowAvatar
  avatarProps={{
    user,
    onClick() {
      openSettings();
    },
  }}
/>

No fun. You might guess there has to be a better way. And you'd be right!

What's containment in React

Containment is a concept where components don't know their children ahead of time. They are simply containers for other elements. Referring to the React Documentation.

This is especially common for components like Sidebar or Dialog that represent generic “boxes”.

How about we apply this to our example. The Header component could look something like so.

function Header({ title, additionalElements }) {
  return (
    <header>
      <h1>{title}</h1>
      <div>{additionalElements}</div>
    </header>
  );
}

Our two examples of using this component now look as follows.

<Header
  title="The Best Website"
  additionalElements={
    <>
      <Icon size="2em" icon="star" />
      <HamburgerButton onClick={() => openMenu()} />
    </>
  }
/>
<Header
  title="New Screen"
  additionalElements={
    <UserAvatar
      user={user}
      onClick={() => {
        openSettings();
      }}
    />
  }
/>

The same could be done using children prop. Let's revisit our previous pain points.

  1. Now the header simply accepts a node and puts it in the correct place, but it's not concerned about what it is and how to render it. Responsibility is now fully on the parent. ✅
  2. To know what's rendered exactly, I just look into the parent and I'm done. I'm able to quickly tell what's going on. Great! ✅
  3. Performance isn't a problem anymore. Regardless of how many times the Header component might re-render internally, the icon, button, or avatar will only re-render when they should. ✅
  4. Reusability is now baked into the API. You can pass anything you want to the component as additionalItems, there's nothing you have to change in Header implementation to have a new item rendered in there. ✅

Conclusion

Simply by moving the component up we solved all problems we've identified - made the code easier to reason about, more flexible, and performant. Composition is a really powerful mechanism, but you have to be cautious to do it right.

Consider asking yourself the following questions.

  1. Does the responsibility span unnecessarily across multiple components?
  2. How long is the path to understanding the code? Can we make it shorter?
  3. Are there any unnecessary re-renders, especially for heavier components that might cause performance issues?
  4. How reusable is the component in its current form?

Adhering to component containment can help you avoid these problems and improve your code on multiple fronts.

Thank you for reading and good luck!

Further reading and references