standel.dev

Optimizing the use of Emotion in React

Ethan Standel 13 min read
Published 7.5.23
React
Styles
CSS-in-JS
Emotion
Performance

What is Emotion?

Emotion is a series of NPM packages that allow for extensible CSS-in-JS dynamic style construction, all under the @emotion/* organization. It is used as the styling engine for several popular component libraries including @mui/* and @chakra-ui/*.

With the @emotion/css library, you can generate styles under a randomized className at runtime which can allow it to be used effectively with or without any framework. The @emotion/react library has special integrations with React that allow for additional optimizations and a React.Context driven theme system. Finally, the @emotion/styled library allows for the creation of React components by only specifying an HTML element name and its associated styles (a pattern borrowed from the styled-components library). There are actually several other libraries in the Emotion ecosystem, but the rest are mostly underlying core libraries, compiler plugins, or testing tools.

If used correctly, Emotion can be a great tool for constructing highly dynamic applications and is performant enough for the task. It also has the DX benefit for React applications that is already found in most alternate framework templates in colocated styles. For example, Vue, Svelte, Astro, and others all allow for component scoped styles in the same file as the component logic and template. This makes for a great developer experience and Emotion brings this to React.

So what is the problem?

Emotion can be slow. It is not always slow and for what it has to do, Emotion is actually fairly performant. However, Emotion inherently has to do more runtime work than using raw CSS, CSS modules, a CSS preprocessor (e.g. Sass/SCSS, Less, or Stylus), or another zero-runtime solution for styles (e.g. TailwindCSS, Compiled, or Vanilla Extract). Raw CSS requires no extra processing. CSS modules, CSS preprocessors, and zero-runtime solutions all require some extra processing at build & dev time, but the resulting CSS is static and can be minified & cached so the client gets optimal performance with these solutions. Emotion, on the other hand, has to do all of the processing at runtime and does comparably similar work to both CSS modules and a CSS preprocessor.

Just how much work is Emotion doing?

Just like at the build time of a CSS preprocessor, Emotion has to parse the style passed into it. Emotion does not simply pass its input strings into a <style> tag, it must parse the style strings into an object tree. The reason your style strings must be parsed by Emotion before the browser is because Emotion offers SCSS-like nesting syntax as it uses stylis as its underlying stylesheet language.

Once the parsing is complete, it then generates a minified CSS output and inserts that value into a <style> tag in the <head> of the document. If you are using SSR then the content will be in the tag as a normal Text node (which is how it content would load if you hand-wrote CSS into a <style> element). For SPA styles, they will be added rule-by-rule directly to a live CSSRuleList of an empty <style> tag for improved performance in cases of more frequent modifications of style rules.

The generic @emotion/css library then returns a string of a random-hashed className that can be used to reference the styles that were just generated. The @emotion/react library handles the order-of-operations here slightly differently by only running style rule insertions when components are being mounted and memoizing the construction of these styles for future renders. There are performance advantages to using @emotion/react over @emotion/css in React applications but in the end, it always has to do all of these steps.

This is a lot of work to do at runtime. The fact that most applications using Emotion don't show obvious performance buckling or dropped frames is impressive to the quality of the engineering at work in Emotion. However, I think it's important to understand here that using Emotion is never necessary and is always a tradeoff for gains in the developer experience at the risk of lost performance. There is nothing that you can do with Emotion that you can't do without it but it enhances code readability and increases developer velocity with the code colocation that it allows for.

How can we optimize our use of Emotion in React applications?

Optimization I. Avoid doing construction of styles in render functions.

Now that it's clear how much work Emotion has to do at runtime, it becomes far more obvious that we want to do this work as infrequently as possible. So this means avoiding using Emotion style constructions in renders as much as we possibly can. Despite almost every example in the Emotion documentation showing style constructions in render functions, this is not the best practice. The documentation, to me, reads as a bit too focused on what is possible with Emotion and not enough on what would be best practice for performance.

So please don't do this...

import * as React from 'react';
import { css } from '@emotion/react';

export const MyButton = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => (
  <button
    css={css`
      padding: .5rem;
      border-radius: .5rem;
      box-shadow: 7px 5px 6px 0px black;
      border: 2px solid navy;
      color: white;
      background: blue;
      transition: .1s ease box-shadow, .1s ease border-color;
      &:hover, &:focus-visible {
        outline: none;
        box-shadow: 7px 5px 10px 0px navy;
        border-color: skyblue;
      }
      &:active {
        box-shadow: none;
      }
    `}
    {...props}
  />
);

Figure 1. A nonoptimal use of Emotion which causes style reconstruction or cache fetching on every render.

Instead, do this...

import * as React from 'react';
import { css } from '@emotion/react';

export const MyButton = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => (
  <button css={styles.button} {...props} />
);

const styles = {
  button: css`
    padding: .5rem;
    border-radius: .5rem;
    box-shadow: 7px 5px 6px 0px black;
    border: 2px solid navy;
    color: white;
    background: blue;
    transition: .1s ease box-shadow, .1s ease border-color;
    &:hover, &:focus-visible {
      outline: none;
      box-shadow: 7px 5px 10px 0px navy;
      border-color: skyblue;
    }
    &:active {
      box-shadow: none;
    }
  `
}

Figure 2. A more optimal use of Emotion which only causes style construction once and never relies on cache fetching.

In the second example here, we are only parsing and constructing the styles once and then reusing the same instance of SerializedStyles for every render of the component. If we do run the construction of styles in the render, Emotion will try to cache the style and fetch it from cache based on the input but this logic is still not free and it's still better to avoid it entirely and not trust that library optimizations will outperform writing code that is optimized from the start.

Optimization II. Ensure that your Emotion styles can be properly minified in your build output.

When creating styles using the css template tag function (like both examples above), the template passed to the function is one large string which is outside of the context of your JavaScript. This means that when standard build tools go through your code to minify the content, they won't touch your style strings. This leaves you with a bunch of extra white space characters in your build outputs that unnecessarily increases the size of your bundle. This problem is made worse if you use css template styles inside of your components as all the space & newline characters from the nesting depth of your component will also be preserved in the output.

import{css}from'@emotion/react';export const MyButton=(props)=>createElement("button",{css:css`
      padding: .5rem;
      border-radius: .5rem;
      box-shadow: 7px 5px 6px 0px black;
      border: 2px solid navy;
      color: white;
      background: blue;
      transition: .1s ease box-shadow, .1s ease border-color;
      &:hover, &:focus-visible {
        outline: none;
        box-shadow: 7px 5px 10px 0px navy;
        border-color: skyblue;
      }
      &:active {
        box-shadow: none;
      }
    `,...props});

Figure 3. The minification output of Figure 1 (not a complete reflection of the exact output of a full production build's minification & uglification).

As you can see, a highly unnecessary amount of the original code structure intended for code readability is left in the output. This issue can be avoided with one of two solutions.

Minification solution 1: Use object styles instead of template string styles for reliable minification.

The first option, which I would personally recommend, would be that you use object styles as opposed to template string styles. This will allow you to write your styles as a plain JavaScript object, with nested selectors as nested objects, and then pass that object to the css function. This will allow almost any build tool to minify style code out of the box as the ending output of everything will be plain JavaScript.

import * as React from 'react';
import { css } from '@emotion/react';

export const MyButton = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => (
  <button css={styles.button} {...props} />
);

const styles = {
  button: css({
    padding: ".5rem",
    borderRadius: ".5rem",
    boxShadow: "7px 5px 6px 0px black",
    border: "2px solid navy",
    color: "white",
    background: "blue",
    transition: ".1s ease box-shadow, .1s ease border-color",
    "&:hover, &:focus-visible": {
      outline: "none",
      boxShadow: "7px 5px 10px 0px navy",
      borderColor: "skyblue",
    },
    "&:active": {
      boxShadow: "none"
    }
  })
};

Figure 4. The object styles equivalent of Figure 2.

Using object styles also has the added benefit of giving you better syntax validation, code highlighting, and auto-completion in your editor as you are writing your styles. Because anything can be a CSS property due to the existence of custom properties/variables, object styles are not strictly typed in TypeScript but they do offer loose autocomplete for known existing properties. You are also far more likely to catch simple syntax errors in object styles.

const brokenStyle = css`
  color: red
  background: blue;
`;

Figure 5. A broken style due to a missing semicolon. No error will be thrown and neither style rule will be successfully applied in the generated class because the missing semicolon will break the parsing of all styles until the next semicolon.

const brokenStyle = css({
  color: "red"
  background: "blue"
});

Figure 6. A broken style due to a missing comma. An error will be thrown and the problem will be highlighted in your editor.

Minification solution 2: Use the @emotion/babel-plugin to minify template string styles.

The second option, and the recommended solution by Emotion, would be to use the @emotion/babel-plugin, which will search your code for uses of css as a template tag function and will minify the content of the template string as if it was just raw Stylis input.

import{css}from'@emotion/react';export const MyButton=(props)=>createElement("button",{css:css('padding:.5rem;border-radius:.5rem;box-shadow:7px 5px 6px 0px black;border:2px solid navy;color:white;background:blue;transition:.1s ease box-shadow, .1s ease border-color;&:hover,&:focus-visible{outline:none;box-shadow:7px 5px 10px 0px navy;border-color: skyblue;}&:active {box-shadow: none;}'),...props});

Figure 7. The minification output of Figure 1 while using @emotion/babel-plugin.

This eliminates the many characters of whitespace which would otherwise be included in your production bundle, and in fact may be the most performant option because this output ends up slightly smaller than minified object styles. It's also worth considering that this input is the native expectation of the underlying Stylis parser, whereas object styles are partially converted to strings before being parsed again into the Stylis AST.

However, this plugin is not always an option. If you're not using Babel or a framework/build-tool that supports Babel plugins, then you'll be entirely without access to this optimization. This leads back to my advice that it is better to write code that is performant from the start, rather than relying on a plugin to fix it for you. Technically we are still relying on the JavaScript build toolchain to minify our code, but this is a much more common and reliable optimization than relying on a specific plugin to minify template string styles.

Optimization III. Consider if you really need Emotion and everything that it offers 🤷‍♂️

The final solution to optimizing use of Emotion in React applications that I would like to propose for consideration is: not using it. There are many situations where you may have to pull in Emotion. If your organization has standardized use of a particular component library which relies on Emotion, like MUI or Chakra, then you already depend on Emotion. You are best off just using it in that case because the tool is already in front of you. So use it, and use it well with the above recommended optimizations.

However, if you are starting a new application and are considering using Emotion, you may want to take a wider look at the rest of the JavaScript ecosystem. There are a lot of great solutions to this problem. Emotion is a well engineered solution and fits very well into React's "just JavaScript" engineering model, but it is not the only solution. With the availability of CSS variables, sharing state to nested CSS has actually become incredibly easy.

Somewhere in your styles...

button.my-button:hover, button.my-button:focus-visible {
  outline: none;
  box-shadow: 7px 5px 10px 0px navy;
  border-color: var(--button-hover-border-color);
}

Somewhere in your JSX...

export const MyButton = ({
  buttonHoverBorderColorOverride = "skyblue",
  ...props
}: { buttonHoverBorderColorOverride?: string } & React.DetailedHTMLProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>) => (
  <button
    {...props}
    className={"my-button " + props.className}
    style={{
      "--button-hover-border-color": buttonHoverBorderColorOverride,
      ...props.style,
    }}
  />
);

Figure 8. Passing state from React to raw CSS

The reason Emotion is chosen for bigger component libraries is that it allows for high customizability with its theme overrides model which is exposed by most component libraries that use it. This allows them to be highly overridable and configurable, but at the cost of a less performant solution to styling. If you are building a new component library, you may want to consider that you can sacrifice the extent of your customizability & configurability in exchange for a more performant solution. This would allow you to compete with bigger component libraries because while they would have the advantages that Emotion affords them, your library could have the performance advantage.

If you really feel that you need what Emotion offers, there are still other alternatives in the runtime CSS-in-JS ecosystem. Stitches is a fantastic alternative of similar features to Emotion. It is a bit more opinionated than Emotion, and only allows for object-styles. However, this means that it doesn't have to support a custom string parser like Emotion does by relying on Stylis. Because of this, Stitches is smaller, and by offering less syntax options it maintains better performance than Emotion in their open-sourced benchmarks.

Conclusion

Emotion is a well engineered, unopinionated, and surprisingly performant ecosystem of tools that may offer the optimal implementation of a sensible solution for a complex problem, but it is not without its drawbacks. It is important to understand the tradeoffs that you are making when you choose to use Emotion or any styling system in your application or library. It is also important to understand that there are many ways to optimize your use of Emotion, and the docs aren't always up-front about it and seem to focus more directly on the enhanced capabilities and developer experience than the potential drawbacks. If you come into a project with all this awareness in mind and are able to account for optimizations slightly earlier, then you can avoid having to do a lot of refactoring later on if Emotion starts to feel like it's not meeting your needs.