← All writing
Craft · · 5 min

@extend considered harmful

SCSS CSS

I generated a single CSS rule with about 4000 selectors in it last month. Not on purpose. I ran the build, the file was 300 KB bigger than I expected, I opened it, and there was one comma-separated selector list running off the side of my editor for what felt like a full minute of scrolling.

That was @extend. I’d been using it wrong in a way that I think is pretty common, so…

Misunderstandings

The pitch for @extend is elegant and it’s why everyone tries it. Instead of a mixin copying the same declarations into 40 places, @extend collects the 40 selectors and hands them one rule to share. You write less, the output is smaller, DRY wins, everybody goes home.

buttons.scss
.btn {
  border-radius: 3px;
  display: inline-block;
  padding: 10px 16px;
}

.btn-primary {
  @extend .btn;
  background-color: #3b7ddd;
}
SCSS

Which compiles to .btn, .btn-primary { ... } and a small rule for the color. Beautiful. Half the size of the mixin version. This is the example in every tutorial including the one I learned it from. It is completely fine, and it is not what happens on a real project.

What it actually does

The tutorial example hides that @extend doesn’t copy declarations down to where you used it. It goes and finds every selector that mentions .btn anywhere in your entire codebase, and adds your selector to all of them.

Every single one. Including .sidebar .widget .btn, and .modal-footer .btn + .btn, and that .ie8 .promo .btn you wrote in a hurry a year ago. Each of those becomes two selectors. And if the thing you extended is itself extended somewhere else, it multiplies.

That’s where 4000 comes from. It’s not a bug, it’s a feature. In fact, it’s the feature working exactly as documented, on a codebase big enough for the documentation to stop sounding reassuring.

And gzip, which is the usual well-actually here, does not save you the way people think. Long repetitive selector lists compress well, that’s true. But the browser still has to parse and match all of them, and a 4000-selector rule is work at runtime regardless of how small it was on the wire.

Stay where you are, dammit

Here’s the one that actually cost me an afternoon rather than a file size:

@extend output appears where the extended rule is, not where you wrote the @extend. So you write .btn-primary at line 400 of your buttons partial, and the resulting selector lands up at line 30 next to .btn, which might be in a different file entirely.

Which means source order changes. Which means the cascade changes. Which means a rule you were relying on to win, because you’d written it later, now loses, because in the output it isn’t later anymore.

I lost a good chunk of a day to a hover state that stopped applying, and the answer was that an @extend three files away had lifted my selector above the thing it was supposed to override. Nothing in the SCSS I was looking at said so. You have to read the generated CSS to see it, and by that point the whole appeal of the abstraction has evaporated.

An alternative approach

Placeholders help with the first problem. If you write %btn-base instead of .btn, the placeholder itself never gets output, so you don’t get the extra selectors from rules you’d forgotten about. It’s a genuine improvement and if you’re going to use @extend at all, use placeholders.

But it doesn’t fix the ordering thing. It doesn’t fix media queries, and at some point I noticed I was maintaining a mental model of where my CSS would end up just to use a feature whose entire schtick was that I’d have to think less.

So mostly I just use a mixin now.

buttons.scss
@mixin btn-base {
  border-radius: 3px;
  display: inline-block;
  padding: 10px 16px;
}

.btn-primary {
  @include btn-base;
  background-color: #3b7ddd;
}
SCSS

Yes, that duplicates the declarations everywhere it’s used. The output is bigger before gzip and roughly a wash after. And in exchange the CSS comes out exactly where I wrote it, in the order I wrote it, and works fine inside a media query, and when I open the compiled file it looks like the file I wrote.

I’ll take the bytes.

The sassy counterargument

One objection is that I’m generalizing from one bad codebase, and that @extend on a small project with placeholders and some discipline is genuinely fine. That’s true. I used it that way for a year and had no problems, and if you’re building something you can hold in your head, none of this will bite you.

Another objection is that mixins can go badly wrong too. They can, mostly when someone writes a mixin that takes 11 arguments and includes it 40 times. Nobody has clean hands here.

But I think there’s a real asymmetry, which is that when a mixin goes wrong, the damage is visible in the file you’re reading. When @extend goes wrong, the damage is in a generated file somewhere else, and the thing you’re reading looks correct. I’d rather have a problem I can see.

I’ve been writing SCSS for the last several months, and there is certainly somebody running @extend beautifully at scale who would call all of this a skill issue. Could well be! But I’ve been bitten 3 separate times now, and the third one was 4000 selectors long. So I’m going to go ahead and let it go.

Read similar posts
10 min

Native nesting and the ampersand

I moved one component off the preprocessor and the card came back as a column of unstyled text with the padding still on it, because every rule in that file whose selector started with an ampersand had quietly stopped being a rule.

9 min

I named it after the page

A landing page needed one block to sit closer to the heading above it, and the modifier I wrote to do that had the name of a page in it.