October 03, 2023

UI Composition

AKA taking a step back to take a step forward
edit

I frequently come across components with either egregious conditionals, bizarre CSS, or both, to solve problems that can be easily solved with composition. It just takes some practice to recognize what responsibilities a particular part of a component should have, and how to properly separate and apply those responsibilities. Let’s make an example.

Let’s say you have a Card component. A Card should have a border and the content inside should be padded from this border. Let’s also give it a box-shadow for some fun. It’ll look like this:

This is a Card

This is the Card’s content. Notice it is padded from the border.

Cool, looks great! But soon you learn that the design team wants to be able to have “sections” in the Card, divided by what is essentially an hr tag. What happens if we do that without making any changes?

This is Section 1

This is the content of Section 1. Isn’t it great?!


This is Section 2

This is the content of Section 2. Isn’t it also great?!

That’s probably not what we want. We want the divider to be able to go the full width of the card, and we want each section to be padded the same. We could try and do some hacky CSS. If I were working in a codebase with this component, I would not be shocked to see something like:

.card hr {
  /* This matches the vertical padding of the wrap */
  margin-top: 1rem;
  margin-bottom: 1rem;
  /* negative margin moves the `hr` to the left side of the box */
  margin-left: -1rem;
  /* 2rem accounts for left and right padding */
  width: calc(100% + 2rem);
}

This might work, but it’s not the most robust solution we can come up with. Rather than write some hacky CSS, we need to take a step back in order to make forward progress again. Composition is going to help us do that.

You see, it might not be obvious, but Card has too many responsibilities. Just like classes, objects and functions can do too much, so can elements with their styling.

When we look closely, we can see Card is doing two separate jobs. First, it’s wrapping all of the children in a border and box shadow. Second, it’s applying a padding between that wrapping border and the children. In order to solve our problem, we need to separate these responsibilities into multiple components.

Let’s break Card into a compound component, with Wrap, Section, and Divider components.

Card.Wrap = function Wrap({ children }) {
  return (
    <div className="rounded border-2 border-gray-300 shadow-[8px_8px] shadow-accent">
      {children}
    </div>
  )
}

Card.Section = function Section({ children }) {
  return <div class="p-4">{children}</div>
}

Card.Divider = function Divider() {
  return <hr class="border-t-2 border-t-gray-300" />
}

Now we can compose our pieces together correctly.

<Card.Wrap>
  <Card.Section>
    <Stack gap={1}>
      <h3>This is Section 1</h3>
      <p>This is the content for Section 1</p>
    </Stack>
  </Card.Section>

  <Card.Divider />

  <Card.Section>
    <p>
      This is an entirely different section. Notice the padding works correctly!
    </p>
  </Card.Section>
</Card.Wrap>

This is Section 1

This is the content for Section 1


This is an entirely different section. Notice the padding and divider work correctly!

This right here, the act of recognizing Card was overloaded with responsibilities and breaking them apart, is the work of building component-based design systems. This is how we make these components robust and flexible.

We can take two steps to make this a bit better. Let’s make the original Card component a sane default composition of the compound components:

function Card({ children }) {
  return (
    <Card.Wrap>
      <Card.Section>{children}</Card.Section>
    </Card.Wrap>
  )
}

This way, when someone needs the simplest Card, they can just use it.

The second way we can improve this is to make it an error to use Card.Section or Card.Divider outside of Card.Wrap using a Context.

// Setting the default to false will let us trigger our Error if necessary
const CardContext = React.useContext(false)

const useCardContext = () => {
  const context = React.useContext(CardContext)

  // We'll set context to true in our Provider, which will avoid this condition
  // We don't even need to return anything from this hook because we're not
  // using the context value anyways.
  if (!context) {
    throw new Error(
      'You may only use Card compound components inside of a `Card.Wrap` component',
    )
  }
}

Card.Wrap = function Wrap({ children }) {
  return (
    // By setting the value to true, we enable our
    // compound components to work safely
    <CardContext.Provider value={true}>
      <div
        style={{
          border: '2px solid var(--colors-offsetMore)',
          borderRadius: 4,
          boxShadow: '8px 8px var(--colors-accent)',
        }}
      >
        {children}
      </div>
    </CardContext.Provider>
  )
}

Card.Section = function Section({ children }) {
  useCardContext()

  return <div style={{ padding: '1rem' }}>{children}</div>
}

Card.Divider = function Divider() {
  useCardContext()

  return <hr />
}

We can take this a step even further and prevent nesting Sections or passing a Divider into a Section, by adding another layer of our context provider in there.

Card.Section = function Section({ children }) {
  useCardContext()

  return (
    <CardContext.Provider value={false}>
      <div style={{ padding: '1rem' }}>{children}</div>
    </CardContext.Provider>
  )
}

I hope your imagination helps you see that this problem affects more than our simple Card component; that there are other offenses of having too many styling responsibilities. One of the most common ones is exporting margins, but I’ll save writing about that for another day.

When you’re struggling to get some UI to work the way you want, try to take a step back to make a step forward. Examine if some part of your component is overloaded, and if separating those responsibilities might not solve the problem.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Need help with your software problems?

My team and I are ready to help you. Hire Agathist to build your next great project or to improve one of your existing ones.

Get in touch
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Agathist
Good software by good people.
Visit https://agath.ist to learn more
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.
Logo for Just Enough Functional Programming
Just Enough Functional Programming
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.