From Styled Components to StyleX
By Matt Soukup (09/01/2024, 04:42 AM CDT )
This post chronicles the conversion of a medium-complex web application, Magic in the Browser (MITB), from Styled Components to StyleX.
TL;DR
StyleX promotes more maintainable component styling practices compared to Styled Components by prohibiting style cascading. I experienced an issue with code-splitting and tree-shaking after moving to StyleX but was able to overcome the problem. StyleX increased the total bundle size. The size of minified component code increased by approximately 28%, but StyleX compresses better. StyleX decreased the total blocking time at page load due to less bootstrapping work (perceivable even on Desktop).
History of CSS in MITB
In its infancy, circa early 2010s, Magic in the Browser was a YUI 3-powered web application. YUI 3 was prima facie still a widget library like its predecessor but under the hood lived a very robust and innovative module system. Magic in the Browser could define a module representing the current page and then declare all of that page’s dependent modules. Loading the page with YUI.use('mitb-page')
would generate a dynamic URL containing all dependencies that pointed to a bundling web service. A CSS file could be represented by a module and declared as a dependency of the module that it styled. In this way, each module had its own separate .css file declared as a dependency. Classes in these files were still globally scoped, so a root classname was needed to contextualize the styles.
Shortly thereafter, NodeJS would arrive on the scene and with it new bundlers like Webpack. These bundlers would obviate the need to explicitly declare module dependenies as they would now be inferred through the NodeJS import tree. A CSS file could be imported directly by a JavaScript file, cueing Webpack to add it to a monolithic CSS bundle. MITB adopted Webpack and continued managing its CSS with separate CSS files per component.
At this point, a natural progression would’ve been to adopt CSS Modules. CSS Modules still prescribed a CSS file per component approach but eliminated the need for an explicit root classname to scope the styles.
Instead, I jumped on the CSS-in-JSS bandwagon. The major draw for me was that by making your CSS manipulable by JS, you no longer needed some extra CSS pre/post-processer like SASS or PostCSS. Why add a separate front-end language to the mix when JavaScript was already in use and more powerful? Additionally, for times when JavaScript needed to know CSS values, the value could be defined once and shared. (this was pre-CSS variables, if memory serves) Out of the available CSS-in-JSS libraries, I chose the React specific Styled Components.
Styled Components Overview
Styled Components generates new React components by augmenting existing components with CSS styles. Styles can be applied to both built-in and custom elements.
Styling with Styled Components looks like this
const Container = styled.div`
background-color: blue;
color: white;
${(props) => props.primary && "font-weight: bold;"}
`;
I chose Styled Components because it was popular and relatively performant. I loved the idea of defining styles using CSS syntax rather than JavaScript objects.
The downside is it’s a runtime solution, so you’re downloading the Styled Components bootstrapping code and can only start processing CSS after JS has been downloaded, parsed, compiled, and executed. JavaScript bytes are expensive.
StyleX Overview
StyleX is the CSS-in-JS library recently opened source by Facebook (teased since Facebook’s redesign in 2019). It boasts the unique approach of generating a single CSS class per “style: value” pair. The argument is that, with a large codebase, CSS classes will repeat the same styling over and over as the application grows. This creates bloated CSS, contributing to a slower page load exprience.
By approaching one class per style, the growth of the app’s CSS payload size approaches zero despite an ever increasing app. StyleX generates unique, short class names to represent these styles for you. This happens as part of the build step, generating a single CSS file that you include at the top of your HTML file.
The downside is it creates a single CSS file containing all styles that will block initial rendering and contain styles unimportant to the critical path.
StyleX looks like this
const styles = stylex.create({
container: {
backgroundColor: 'blue',
color: 'white',
},
primary: {
fontWeight: 'bold',
},
});
const Container = () => {
return <div {...stylex.props(styles.container, props.primary && styles.primary)}>{...}</div>;
};
The Migration
Role of Props
Styled Components create components that can accept props that vary styles. Components are defined with JavaScript template literals with embedded anonymous functions that receive props and return conditional styles.
Stylex does not produce new components but only style-related props that are spread on existing components. Props to your component can control which “namespaces” get conditionally applied to your components.
Scoped Styles
Because Styled Components lets you write arbitrary CSS, selectors targeting nested elements (built-in elements or other Styled Components) were prevalent. For example, a List element could define a CSS rule targeting a List Item component. This leads to an undesirable tighter coupling.
With StyleX, scoped styled do not exist. Styles are only set on the specific element that is being targeted via ...stylex.props()
(akin to setting the style
property directly).
Common Components
MITB has a common component library. With Styled Components, common components were customized with props or by “extending” the base component to a new component with custom styles. With StyleX, the same components exist, but they are customized by props or passing a style
prop.
Take the common Button component. It has styles for hover, active, color scheme, disabled, etc. Additionally, the same button styles can be applied to an anchor tag- a ButtonLink.
The Styled Components version:
export const Button = styled.button`
cursor: pointer;
border-radius: 4px;
/_ ... _/
background-color: ${(props) =>
props.primary ? colors.darkText : colors.transparentBackground};
:hover {
${hoveredButton}
}
:active {
${depressedButton};
}
&[disabled] {
cursor: default;
/_ ... _/
}
${(props) => props.depressed && depressedButton};
`;
export const ButtonLink = styled(Button.withComponent("a"))`
display: inline-block;
text-decoration: none;
font-family: ${fonts.base};
`;
The StyleX version:
const styles = stylex.create({
// ...
button: {
cursor: "pointer",
borderRadius: "4px",
// ...
},
buttonDark: {
backgroundColor: colors.darkText,
},
buttonLight: {
backgroundColor: colors.transparentBackground,
},
depressedButton: {
backgroundImage: "linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.15))",
boxShadow:
"0 0 0 1px rgba(0, 0, 0, 0.25) inset, 0 2px 4px rgba(0, 0, 0, 0.3) inset",
},
buttonDisabled: {
cursor: "default",
// ...
},
buttonLink: {
display: "inline-block",
textDecoration: "none",
cursor: "pointer",
fontFamily: fonts.base,
color: colors.lightText,
},
// ...
});
export const Button = forwardRef(function Button(
{ tag = "button", style, primary, depressed, ...restProps } = {},
ref
) {
const DynamicTag = tag;
return (
<DynamicTag
{...stylex.props(
styles.button,
primary ? styles.buttonDark : styles.buttonLight,
depressed && styles.depressedButton,
restProps.disabled && styles.buttonDisabled,
style
)}
ref={ref}
{...restProps}
></DynamicTag>
);
});
export const ButtonLink = ({ style, ...restProps }) => (
<Button tag="a" style={[styles.buttonLink, style]} {...restProps} />
);
TypeScript supercharges these types of “common components”. Because all styles are typed, component authors can define allowable properties of the style
prop meant to customize the component. (This is another great incentive to switch MITB to TypeScript in the future.)
CSS Variables
StyleX requires using literal values (strings, numbers) for the style properties unless you opt into the CSS Variable support. To use CSS variables, define a separate file with extension .stylex.js.
export const colors = stylex.defineVars({
placeholder: "#cccccc",
// ...
});
This is useful to reuse style values across your project.
Additionally, the variables can have their values adjusted in real-time in response to user interaction or media queries. As an example, here’s a trick using vars for changing the text color of the ::placeholder
pseudo-element of a text input:
// styles.stylex.js
export const workaroundVars = stylex.defineVars({
inputColor: null,
});
// component.js
import { workaroundVars } from "../../core/styles.stylex";
const styles = stylex.create({
textInput: {
[workaroundVars.inputColor]: {
default: null,
":focus": "#aaaaaa",
},
"::placeholder": {
color: workaroundVars.inputColor,
},
},
});
Another trick using variables to allow a hovered parent to influence a child:
const styles = stylex.create({
submenuItemLink: {
[workaroundVars.borderBottomColor]: {
default: "transparent",
":hover": colors.lightText,
},
},
submenuItemLinkText: {
borderBottomWidth: `${submenuItemBorder}px`,
borderBottomStyle: "solid",
borderBottomColor: workaroundVars.borderBottomColor,
},
});
Workarounds
Custom classnames
Because stylex hijacks the classname and style properties of an element, if your element requires a custom classname, you can employ a trick to get that custom classname to stick:
const customClassname = {
'custom-classname': 'custom-classname',
$$css: true,
};
// ...
<div {...stylex.props(styles.menu, customClassname)}>
This is essentially the format StyleX compiles down to. It is not part of the standard API, so use with caution.
Dynamic Styles
For cases where the style value is more “continuous”, a “namespace” can be defined not as an object but as a function that returns an object:
const styles = stylex.create({
manaSymbolLayoutMarginTop: (marginTop) => ({
marginTop: `${marginTop}px`,
}),
});
Challenges
After all code was migrated, I went through the UI with a fine-toothed comb and smoothed out the differences. Everything seemed ready to ship until I examined the generated bundle and found, to my dismay, that the main bundle size had grown substantially. It appeared as though StyleX’s processing had interfered with tree shaking and ignored my code splitting, placing everything into a giant bundle. A GitHub issue pointing to the Webpack plugin was the likely culprit. A different issue declared that the esbuild plugin worked great. Sounded like a good time to finally ditch Webpack and adopt esbuild.
Disclaimer: the Webpack problem I experienced may have been solvable in some other way while staying on Webpack. Changing bundlers is an extreme measure, but moving to esbuild was on my roadmap, anyway. If memory serves, I may have still experienced some tree-shaking and code-splitting issues after switching to esbuild. Perhaps, it was modifying Babel to generate esm that fixed my issue, but I’m not certain at this point.
Webpack to esbuild
esbuild replaced Webpack and Babel, and I threw out core-js with it. I learned later that the esbuild StyleX plugin still brings in Babel, which must still be configured with some plugins. I believe this is due to esbuild’s extension points not giving full AST access like a Babel plugin would.
esbuild is FAST!
This is not a scientific comparison as there were some babel only plugins without an esbuild equivalent meaning babel was doing some extra work. Think of this as a “high level” comparison between a Webpack-based bundling and an esbuild-base bundling of the same application, just to give you an idea of the differences.
Running front end build with linux time
:
Webpack | esbuild | |
---|---|---|
real | 0m36.270s | 0m14.682s |
user | 1m11.207s | 0m19.242s |
sys | 0m7.820s | 0m3.535s |
Configurations
esbuild without StyleX was generating esm modules but generated cjs modules after StyleX was brought into the fold. Thankfully, the StyleX plugin accepts a Babel config. This is the StyleX esbuild plugin configuration that outputs esm modules with code splitting and tree shaking working as expected:
stylexPlugin({
babelConfig: {
presets: [
['@babel/preset-env', { targets: { esmodules: true }, modules: false }],
['@babel/preset-react', { runtime: 'automatic' }],
],
},
dev: false,
generatedCSSFileName: path.resolve(__dirname, `${outputDir}/styles.css`),
unstable_moduleResolution: {
type: 'commonJS',
rootDir: __dirname,
},
}),
All files containing jsx were also changed from .js
to .jsx
files to work with the esbuild plugin.
Performance Comparison
Styled Componenents Version: 5.3.6
Disclaimer: 6.x was the most recent major version at the time of this comparison, but I didn’t want to bother with a major upgrade before migrating to StyleX.
styled-components babel minification enabled in esbuild-based build via the esbuild-plugin-styled-components
package with minify
, transpileTemplateLiterals
, and pure
options enabled.
StyleX Version: 0.7.5
Bundle Size
Comparisons were made using the esbuild Bundle Size Analyzer.
Styled Components esbuild bundle
StyleX esbuild bundle
Unexpectedly, the StyleX bundle is ~138KB larger, and this doesn’t count the fact that 55KB of generated StyleX CSS is now outside of the bundle. Note that these comparisons are just on the minified code and do not take compression into account.
3rd Party Dependencies
Let’s first examine the third party dependencies, primarily the CSS libraries. At minimum, we should expect that styled-components
and its dependencies @emotion
and stylis
should be absent from the StyleX-based bundle. It is true that 20KB from styled-components
was removed but only 60% (13KB) of its @emotion
dependency was removed and the full stylis
(6.7KB) remained. It turns out another dependency (react-select
) still depends on @emotion
and stylis
. StyleX brought in its own runtime (@stylexjs
) adding 3K to the bundle. The Styled Components branch brought in styled-normalize
(2KB) while StyleX brings in the raw normalize.css
(1.7KB) (pretty much a wash). Apart from some insignificant changes in other modules due to tree shaking, no other differences existed in terms of third party dependencies. Thus, StyleX saved approximately 30.3KB here (but had the potential to save 45.3KB if nobody else depended on @emotion
and stylis
). This comfortably makes up for the extra generated CSS file (especially after compression is taken into account).
MITB Sourcecode
We now turn to the MITB source code. StyleX shows a whopping 28% increase (171KB) in the components directory. Across the board, the component JavaScript is larger for StyleX, which is unexpected given StyleX can strip all of the CSS definitions while Styled Components should still contain that CSS.
Turns out this is less common as style definitions can only fully be stripped if (1) the styles used are all local to the file (not imported) and (2) the styles are not applied conditionally. Otherwise, the verbose style definitions stick around but with the style values replaced with their classname, which is often-times longer than the style value itself.
Even when the style definitions are completely stripped away and replaced with classes at build time, the classname attribute requires significant space. Styled Components calculates these classes at runtime, but with StyleX, you pay for it over the wire.
Because StyleX prescribes “All styles on an element should be caused by class names on that element itself.”, styles no longer “cascade”. With Styled Components, using complex selector rules, cascading selectors act as a sort of “compression” relative to single-style classnames.
The following table describes differences in compression of the index JavaScript bundle.
Styled Components | StyleX | |
---|---|---|
raw | 755.7 KB | 866.6 KB |
gzip | 204.5 KB (73%) | 215.3 KB (75%) |
br | 165.3 KB (78%) | 171.5 KB (80%) |
StyleX compresses a little better, which would make sense given the style object key names are repeated between the definition and props usage, and the style names themselves (paddingLeft
, borderLeftColor
, etc.) are repeated amongst the style definitions.
StyleX Generated CSS File
Lastly, a note on the StyleX generated CSS. It weighs in at 55KB, but setting the StyleX useCSSLayers: true
option reduces it to 31KB by avoiding the CSS Layers polyfill. The option hasn’t been enabled yet because it will take additional work to properly handle normalize.css in a world of CSS layers. The polyfill does compress very well though. To give you an idea, the size of the brotli compressed CSS that uses the polyfill is 9.6KB (83% compression ratio) versus the brotli compressed version using CSS Layers being 9.3KB (70% compression ratio).
Web Vitals
I ran Google’s hosted pagespeed service against each environment three times. For Desktop, there was little difference in performance. StyleX had 100ms less total blocking time. The Mobile view demonstrated some starker differences.
The results are displayed in the tables below:
Run 1 | Run 2 | Run 3 | |
---|---|---|---|
Performance | 71 | 68 | 68 |
FCP | 2.3s | 2.4s | 2.4s |
LCP | 3.4s | 3.5s | 4.0s |
Total Blocking Time | 710ms | 810ms | 600ms |
Speed index | 2.9 | 3.5 | 2.9 |
CLS | 0.039 | 0.039 | 0.039 |
Run 1 | Run 2 | Run 3 | |
---|---|---|---|
Performance | 76 | 76 | 73 |
FCP | 2.3s | 2.3s | 2.3s |
LCP | 3.7s | 3.7s | 3.8s |
Total Blocking Time | 410ms | 430ms | 520ms |
Speed index | 2.8 | 2.9 | 2.7 |
CLS | 0.039 | 0.039 | 0.039 |
StyleX grinds much less. The total blocking time was consistently lower. The Styled Components runs complained about too much main thread work and js execution time.
The other categories were consistent between the two:
- Accessibiity: 94
- Best Practice: 100
- SEO: 83
Takeaways
Component styling is more maintainable with StyleX. Components’ styles no longer reference other components (looser coupling). Style specificity (for the most part) no longer needs attention. Just by looking at a StyleX component, you can see where all of the styles originate and which styles will “win out”.
StyleX’s performance profile is also an improvement. The difference in performance between the two libraries seems inconsequential on Desktop, but StyleX shines on mobile where a snappy interface is key. Because MITB is service-worker-backed and infrequently updated, a lower total blocking time is preferred to a lower bundle size. StyleX doesn’t even add that much to the bundle size, and other apps would see a less dramatic rise than MITB given @emotion
and stylis
are still coming in. It’s also worth reiterating that, with StyleX, as the app grows, the total bytes of CSS should remain near current levels.
The migration was a ton of work. From manually migrating the style rules to changing bundlers to fixing bugs, the StyleX migration took much longer than anticipated. Though, moving to a simpler and faster bundler in esbuild also checked off a major road-map item. Ultimately, I’m happy with the decision to have adopted StyleX. While it came with an opportunity cost of more features, it begins to pave the way for MITB’s mobile presence.
Thanks for reading!