Skip to main content

Layout-free components

How to compose reusable components


For a component to be reusable, it shouldn't care about its surroundings. Adding layout properties, such as margins or dimensions, to a component breaks encapsulation. As Max Stoiber said in his article about banning margins from components, good components are usable in any context or layout. I've noticed this principle can be challenging to grasp, so I'll do my best to explain why and how I apply it.

Let's start with a sample layout:

Stacked components

Here is the same layout in simplified JSX:

const ComponentA = () => <div>Component A</div>
const ComponentB = () => <div>Component B</div>
const ComponentC = () => <div>Component C</div>
const ComponentD = () => <div>Component D</div>

const Composition = () => (
  <>
    <ComponentA />
    <div>
      <ComponentB />
      <ComponentC />
    </div>
    <ComponentD />
  </>
)

Next, I want to add some layout styles to our composition to achieve the stacked layout in the sample image. There are at least three ways to handle this:

  1. Handle spacing within the component.
  2. Handle spacing when consuming the component.
  3. Handle spacing on the parent level.

Let's discuss the pros and cons of each approach.

Link to this headingHandle spacing within the component

This is the approach I see often used, and it's no wonder: it's simple, quick, easy to understand and gets the job done. Here's how this approach could look:

const ComponentA = () => <div style={{ marginBottom: "24px" }}>Component A</div>
const ComponentB = () => <div style={{ margin: "16px" }}>Component B</div>
const ComponentC = () => <div style={{ margin: "16px"; marginTop: 0 }}>Component C</div>
const ComponentD = () => <div style={{ marginTop: "24px" }}>Component D</div>

const Composition = () => (
  <>
    <ComponentA />
    <div>
      <ComponentB />
      <ComponentC />
    </div>
    <ComponentD />
  </>
)

This works. I call it a day and feel good. Job well done.

Then, a couple of days later, I realise I want to duplicate ComponentB above its current container. I fire up my editor and change the markup to this:

const Composition = () => (
  <>
    <ComponentA />
    /* I added Component B also here */
    <ComponentB />
    <div>
      <ComponentB />
      <ComponentC />
    </div>
    <ComponentD />
  </>
)

I save my changes, open the browser and am greeted with this:

Moving a component out of its existing context may result in an unwanted layout when layout is handled on a component level.

Well, that looks odd. Aiming for a fix, I go to ComponentB and adjust its styles to suit the new placement. After some tweaking, I realised it's challenging to create a reusable component with spacing baked in. It's next to impossible to include layout styles that suit every use case. What could I do about this?

Let's see our next option.

Link to this headingHandle spacing when consuming the component

Fed up with customising or overriding my component styles, I'm leaning towards alternatives. Next, I thought I'd extract the spacing styles from the component level and define component spacings when using them. Here's what that could look like:

const ComponentA = () => <div>Component A</div>
const ComponentB = () => <div>Component B</div>
const ComponentC = () => <div>Component C</div>
const ComponentD = () => <div>Component D</div>

const Composition = () => (
  <>
    <ComponentA style={{ marginBottom: "24px" }}/>
    /* Note the different margins on this instance! */
    <ComponentB style={{ marginBottom: "24px" }}/>
    <div style={{ borderRadius: "8px"; border: "1px solid gray" }}>
      <ComponentB style={{ margin: "16px" }}/>
      <ComponentC style={{ margin: "16px"; marginTop: 0 }}/>
    </div>
    <ComponentD style={{ marginTop: "24px" }}/>
  </>
)

Now the layout looks good again. Problem solved!

However, after a few days, I return to the code to adjust the layout. I want to move the topmost ComponentB below ComponentC. I cut ComponentB and paste it below ComponentC. But when I save the changes, I see something unexpected:

Moving components around resulted in an unwanted layout.

The layout is broken again. What happened? I forgot to adjust the layout styles I passed to the component. Then I realise that I need to remember to do that every time I want to use the component in a new environment or move it. That's no way to live my life. There must be a better way.

The good news is, there is a better way.

Link to this headingHandle spacing on the parent level

Struggling with the spacing challenges, I have an epiphany: what if I could take the spacing responsibility out of the components? With this approach in mind, I try the following:

<style>
  * {
    margin-top: 24px;
  }
  .box {
    padding: 16px;
    border: 1px solid gray;
    border-radius: 16px;
  }
</style>

const Composition = () => (
  <>
    <ComponentA />
    <ComponentB />
    <div className="box">
      <ComponentB />
      <ComponentC />
    </div>
    <ComponentD />
  </>
)

I get this result:

Moving the spacing to the parent by using the universal selector.

Despite my deep-rooted fear of performance issues, I used the universal selector * to add spacing to all my elements. The result is promising, but it has problems: I don't want to add spacing to the first element within the container. How could I tackle that?

Searching for a solution, I stumbled upon an article by Heydon Pickering about the lobotomised owl selector, which introduces a clever * + * selector. The selector combines the universal selector with the adjacent sibling selector. This combination will select the next element of every element. That sounds exactly what I'm looking for, so I give it a spin and alter my styles:

* + * {
  margin-top: 24px;
}

Hands sweating, I save the changes and see this:

Moving the spacing to the parent by combining the universal and adjacent sibling selectors.

Yes! That's it. I've found the ideal solution!

Or that's what I thought.

When I inspect the result closely, I see that the gap within the box between ComponentB and ComponentC has the same margin as the top-level container, whereas I would like it to have a smaller gap. Could my newfound solution handle this exception?

What if I used a more specific selector for the content I want to adjust? Let's try the following:

* + * {
  margin-top: 24px;
}

.box * + * {
  margin-top: 16px;
}

Here's the result:

Using another selector for content with different layout requirements.

Yay, it worked!

A couple of days later, still thrilled by this epiphany, I dig deeper and realise that modern CSS, with its grid bells and flexbox whistles, can make my life even easier. I can replace the owl selector with the gap property, which is supported in all modern browsers.

With this knowledge, I refactor my spacing game for good. I add a top-level aside element to act as the parent for my styles.

<style>
  .parent {
    display: flex;
    flex-direction: column;
  }
  .aside {
    gap: 24px;
  }
  .box {
    padding: 16px;
    border: 1px solid gray;
    gap: 16px;
    border-radius: 16px;
  }
</style>

const Composition = () => (
  <aside className="parent aside">
    <ComponentA />
    <ComponentB />
    <div className="parent box">
      <ComponentB />
      <ComponentC />
    </div>
    <ComponentD />
  </aside>
)

With the spacing responsibility on the parent level, the container will always have you covered, no matter where you move the components.

I've been using this approach for some years. While the approach is almost always applicable, there are some use cases when you might need an escape hatch. For example, when you need a consistent inline margin and a gap between elements, that might be harder to handle from the parent. In such cases, I pass spacing styles to the component, as in the second option.

That's it! I hope this helped you to understand how you can improve your component layout by moving the spacing responsibility where it usually belongs: to the parents.

Get in touch

I'm not currently looking for freelancer work, but if you want to have a chat, feel free to contact me.

Contact