Change how you write your CSS-in-JS for better performance
· Michael Dougall · 5 min readCSS-in-JS is awesome. When it was gaining traction I remember how freeing it was to use it – in a time when custom properties weren't widely available they allowed us to create rich dynamic experiences right inside JavaScript! Even better consuming a component library from NPM without needing inane bundler configuration was made into a reality, just import and go!
While working at Atlassian I've had the privilege to work on the Atlassian Design System which leans heavily on CSS-in-JS to author styles. During this time I've seen components styled with styled-components and emotion, styles defined using strings & objects, and the costs you wouldn't immediately think about such as performance gotchas, difficulty in understanding style declarations, and evolution stagnation.
What if we could change how we write styles to improve these areas without needing to jump to another library [just yet]?
Colors of the rainbow
One of the benefits of CSS-in-JS is that it is very flexible, however that is also one of its biggest drawbacks. The experiences we write today are very dynamic, different appearances, states, and sizes, all with a few different ways to implement. One common way I've seen combines styles using conditional object spreads [or even worse if statements] but given a large enough prop set you're at risk of a combinatorial explosion! Consider that over time a component could go through multiple engineers, even teams! That's a lot of potential misunderstanding.
Let's have a play and see what that looks like in practice, press the button to add more props.
const styles = {
borderRadius: 3,
color: 'black',
backgroundColor: 'lightgrey',
};
function Lozenge({ children }) {
return <span css={styles}>{children}</span>;
}
It's increasingly more complex to understand this declaration both as a human and static analysis tool! Styles built up and returned from a function and the indirection from pass-through arguments make the link between input and usage blurry at best. It can be confusing to understand what the current state of a component could be given a particular set of inputs.
Pulling back from infinity
As a codebase grows I find myself wanting to automate away all of the nitty gritty, and work towards the holy grail of code consistency. Amusingly however if you take the code above it turns out not having clear call sites for style declarations and complex style combination makes it difficult, almost impossible to successfully lint and evolve the authored styles at scale.
If we could come up with a list of goals to write the best CSS (in JS) what would they be? My list includes:
- Clear call sites
- Typed declarations
- Easily scanned and understood by engineers
- Encourages statically declared styles
- One way to style
To enact these we could set some constraints. My list of constraints ends up being:
- Use CSS prop
- Use JS objects
- Styles must be declared in the top level scope
- Styles can't be imported/exported
- Styles can't be nested
- Styles can't be composed together [in their own declarations]
What would yours look like? Have a play and see what it looks like when we constrain the styles [press the button on the right!] from the example above.
const styles = {
borderRadius: 3,
color: 'black',
backgroundColor: 'lightgrey',
};
function Lozenge({ children }) {
return <span css={styles}>{children}</span>;
}
If you're feeling what I'm feeling you'll notice the output of this appears very similar to how you'd use CSS modules! Simple bite sized style declarations that are composed together in the owning component. Total lines of code may increase but the connections and flow analysis we gain more than makes up for it.
Tying it all together
Imposing constraints correlates to better outcomes in code even if it isn't immediately obvious. Let's walk through a few examples of what is improved because of the constraints we've applied.
Performance
Let's start with the more interesting aspect around performance. These constraints mean less styles are generated when rendered on the server! This is exacerbated when styles can't be known ahead of time, for example when all users have their own image. Notice there is a style declaration for every unique avatar, versus a single one when the constraints are applied.
Have a play and see what it looks like when we constrain the styles [press the button on the right!].
<div class="css-1udhswa"></div>
<style>
.css-1udhswa {
border: 2px solid currentColor;
border-radius: 50%;
width: 40px;
height: 40px;
display: inline-block;
background-color: currentColor;
overflow: hidden,
background-size: contain;
background-style: url(https://i.pravatar.cc/150?u=0);
}
</style>
Having truly dynamic styles flow through the style prop is something we should keep in our toolbox, and these constraints really make us use it. Static styles flow through the CSS prop, dynamic styles flow through the style prop, and we generate less styles on the server? Sound pretty great to me.
Code analysis
Imagine the scenario described earlier where styles are defined with no clear call site and use nested conditional rest objects and if statements, and then also imagine trying to write a lint rule for this, it's no easy feat let me tell you!
These constraints are fantastic for the fact that you can write some very powerful rules for how these styles should be written. Ordering, disallowed styles, enforced color usage, even through to controlling how they are dynamically applied through CSS prop. Nothing is off the table.
const focusRing = {
boxShadow: '0 0 0 2px blue',
};
Style declarations written with these constraints are clear and simple, so simple in fact that statically analyzing them can be done exhaustively.
Code evolution
A logical next step when the code you write is easily statically analyzable is that you can change it [at scale]. Using compile time CSS-in-JS solutions are gaining in popularity so if you're aiming to min-max your app you'll definitely want to consider using them. Let's say we are.
Because we know what the floor [worst case] is for where static styles flow [CSS prop] and dynamic styles flow [inline styles] we're in complete control.
Here's an example that transforms emotion code to use a library called vanilla-extract.
import { css } from '@emotion/react';
const styles = css({
outline: 0,
border: 0,
backgroundColor: 'blue',
});
const disabledStyles = css({
opacity: 0.5,
backgroundColor: 'gray',
});
function StyledComponent({ isDisabled }) {
return <div css={[styles, isDisabled && disabledStyles]} />;
}
More than just styling
Hopefully you've found this read as interesting as I did when I first started thinking about it. Finding constraints to apply to the code we write applies to more than just styling our experiences however!
Next time you're deep in your code base think through what opportunities might arise if you applied more constraints to how you write state management, components, define APIs, even features that you ship [or don't]. You'll be surprised at the possibilities!
Let me know on Twitter what you thought of this blog! More to come.