@extend considered harmful
On the 4,000-selector rule I generated by accident, why the output moves, and the case for using a mixin like a normal person.
I generated a single CSS rule with about four thousand selectors in it last month. Not on purpose. I ran the build, the file was 300kb 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, and 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 forty places, @extend collects the forty selectors and hands them one rule to share. You write less, the output is smaller, DRY wins, everybody goes home.
.btn {
border-radius: 3px;
display: inline-block;
padding: 10px 16px;
}
.btn-primary {
@extend .btn;
background-color: #3b7ddd;
}
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, and it is completely fine, and it is not what happens on a real project.
What it actually does
The thing the tutorial example hides is 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.
It doesn't copy declarations down to where you used it. It adds your selector to every rule that mentions the thing you extended.
And gzip, which is the usual rejoinder 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 any more.
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, and 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 in order to use a feature whose selling point was that I’d have to think less.
So mostly I just use a mixin now.
@mixin btn-base {
border-radius: 3px;
display: inline-block;
padding: 10px 16px;
}
.btn-primary {
@include btn-base;
background-color: #3b7ddd;
}
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, and they can, mostly when someone writes a mixin that takes eleven arguments and includes it forty 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 out there running @extend beautifully at scale who would read this whole post as 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.