Skip to main content

CSS-in-js problems and solutions

CSS-in-JS - a styling pattern where CSS lives as JavaScript template literals or objects inside component files, with the library generating unique hashed class names at runtime.

Theory

TL;DR

  • Analogy: recipe card stapled to the dish - styles travel with the component and never leak into other components
  • Main trade-off: prop-based dynamic styles, but 10-50KB of JS runtime and extra parse time on every render
  • The three real problems: render performance, bundle size, SSR hydration mismatches
  • Decision rule: dynamic styles over ~20% of the app - use CSS-in-JS; mostly static layout - use CSS Modules or Tailwind
  • For SSR-heavy apps: Linaria or Vanilla Extract extract CSS at build time, zero runtime cost

Quick example

jsx
// styled-components: class generated at runtime, scoped automatically import styled from 'styled-components'; const Button = styled.button` background: ${props => props.primary ? 'blue' : 'gray'}; color: white; padding: 10px; `; // <Button primary>Click</Button> // Output: <button class="sc-a-xyz123"> with a <style> tag injected in <head> // Class name is a hash of the style rules - zero collision with global CSS

The library hashes the template literal, injects a rule via sheet.insertRule(), and caches it. On re-render with identical props, the cached hash is reused - no DOM write.

The three problems

1. Render performance

Styled-components and Emotion inject styles into the DOM as JavaScript runs. The browser parses JS, executes style functions, hashes the output, and only then paints. In a 1000-item list without memoization, that means 1000 unique insertRule() calls per render cycle. Benchmarks from the React community show this can drop frame rate from 60fps to around 10fps on large lists.

2. Bundle size

Styled-components ships ~20KB gzipped at runtime. Tailwind after PurgeCSS sits around 10KB with no runtime at all. For most apps the gap is not visible. For performance-critical pages like product listings or dashboards loading on mobile, it matters.

3. SSR hydration mismatch

If styles inject only client-side, the server sends HTML without class definitions. React hydrates, styles appear, and the user sees a flash of unstyled content (FOUC). The libraries have fixes - ServerStyleSheet in styled-components, extractCritical in Emotion - but they require explicit setup.

When to use

  • Theme-heavy app with runtime color or spacing switching - styled-components or Emotion
  • Static marketing site or blog - CSS Modules
  • Server-rendered app where first paint speed is measured - Linaria (zero-runtime)
  • Rapid prototype with a team unfamiliar with CSS architecture - Tailwind

Comparison table

AspectCSS-in-JS (runtime)CSS ModulesTailwind
ScopingRuntime hashed class namesBuild-time local IDsUtility classes
Dynamic stylesNative via propsWorkarounds neededArbitrary values only
Bundle impactRuntime JS (~10-50KB)Zero runtime~10KB after purge
SSR setupRequires extra configWorks by defaultWorks by default
When to useDynamic UIs, theme systemsStatic componentsPrototyping, MVPs

How it works internally

Styled-components uses a Babel plugin to extract template literals at parse time. At runtime, V8 executes the style function, hashes the result via an internal hashObject(), and injects rules into the CSSOM via CSSStyleSheet.insertRule(). A StyleSheet Map acts as a cache - if props are shallow-equal, the hash is reused and no DOM write happens. For SSR, ServerStyleSheet.collectStyles() captures all rules during server render and injects them inline in <head> before the client hydrates.

Common mistakes

1. Generating styles inside loops without memoization

jsx
// Wrong: new hash per item, 1000 rules inserted on every render const ListItem = ({ id }) => { const style = css`color: hsl(${id % 360}, 70%, 50%);`; return <li css={style}>Item {id}</li>; }; // Fix: compute hash once per unique id const ListItem = memo(({ id }) => { const style = useMemo( () => css`color: hsl(${id % 360}, 70%, 50%);`, [id] ); return <li css={style}>Item {id}</li>; });

Without useMemo, every parent re-render creates a new hash and triggers a DOM write. This is the most common source of CSS-in-JS slowdowns in production.

2. Skipping SSR style extraction

jsx
// Wrong: styles inject post-hydration, user sees FOUC export default function App() { return <ThemeProvider theme={theme}><Page /></ThemeProvider>; } // Fix with styled-components in Next.js _document.js const sheet = new ServerStyleSheet(); ctx.renderPage = () => originalRenderPage({ enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), }); // Styles shipped in <head> before client hydration

3. Forwarding non-style props to the DOM

jsx
// Wrong: React warns about unknown DOM attribute, prop leaks to HTML const Box = styled.div` opacity: ${props => props.isVisible ? 1 : 0}; `; // Fix: tell styled-components not to forward the prop const Box = styled.div.withConfig({ shouldForwardProp: (prop) => prop !== 'isVisible', })` opacity: ${props => props.isVisible ? 1 : 0}; `;

4. Using runtime CSS-in-JS with React Server Components

Server Components run without a browser JS context. Styled-components and Emotion cannot inject styles there. The fix: switch to Linaria or Vanilla Extract, which extract CSS at build time and produce static files that work anywhere.

Real-world usage

  • Vercel dashboard: styled-components with ThemeProvider for per-user theme preference switching
  • Chakra UI: Emotion with shouldForwardProp to keep the DOM clean
  • Remix: Linaria extracts to static CSS files for server-rendered routes
  • Storybook: Stitches for design token props in shared UI kits
  • Gatsby static blogs: Emotion with babel-plugin-emotion for zero-runtime overhead

Follow-up questions

Q: What is the actual runtime cost of styled-components in a 1000-item virtualized list?
A: The library itself is ~50KB gzipped. The real cost is cache misses - if styles recalculate on each render, you get 1000 insertRule() calls per paint cycle. Use React.memo and memoize dynamic style objects to reduce that to near zero.

Q: How does CSS-in-JS handle SSR hydration?
A: The server collects all styles during render via ServerStyleSheet.collectStyles() or extractCritical(), then injects them as a <style> block in the HTML head. On the client, React matches the pre-rendered HTML. Without this setup, the first paint has no class definitions and FOUC appears.

Q: When does Tailwind beat CSS-in-JS on bundle size?
A: For static apps almost always - Tailwind purges unused utilities and ships ~10KB with no runtime. Styled-components adds ~20KB of JS that must execute before styles appear. In a highly dynamic app with hundreds of unique runtime-generated styles, total CSS from CSS-in-JS can be smaller because unused rules simply do not exist.

Q: How do you share a CSS-in-JS style cache across micro-frontends using Module Federation?
A: Expose StyleSheet.getInstance() as a singleton via the webpack provider. Without this, each micro-frontend initializes its own cache and duplicate rules get injected. This pattern is used in Nx monorepos with shared Emotion instances.

Q: Why does switching to zero-runtime Linaria break dynamic props?
A: Linaria extracts styles at build time, so it cannot evaluate runtime values like props.color. You replace dynamic styles with CSS custom properties (var(--color)) set inline on the element. You keep component-scoped class names but lose JS interpolation inside style definitions.

Examples

Basic: button with prop-based styles

jsx
import styled, { ThemeProvider } from 'styled-components'; const theme = { colors: { primary: '#007bff' } }; const Button = styled.button` background: ${props => props.primary ? props.theme.colors.primary : 'gray'}; color: white; padding: 10px 20px; border: none; border-radius: 4px; `; function App() { return ( <ThemeProvider theme={theme}> <Button primary>Save</Button> <Button>Cancel</Button> </ThemeProvider> ); } // Output: two buttons, unique hashed classes, both isolated from global CSS

ThemeProvider injects the theme via React context. Any styled component inside it reads props.theme without prop drilling. Change the theme object once and every component that uses it updates.

Intermediate: memoized metric cards in a live dashboard

jsx
import { css } from '@emotion/react'; import { memo, useMemo } from 'react'; // Defined outside component - hash computed once, cached for the app lifetime const cardBase = css` border-radius: 8px; padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); `; const MetricCard = memo(({ label, value, color }) => { // Only recalculates when color changes, not on every parent re-render const valueStyle = useMemo( () => css`color: ${color}; font-size: 24px; font-weight: 600;`, [color] ); return ( <div css={cardBase}> <span>{label}</span> <span css={valueStyle}>{value}</span> </div> ); }); // Usage in a real-time dashboard <MetricCard label="Revenue" value="$10k" color="#22c55e" /> <MetricCard label="Users" value="1.2k" color="#3b82f6" />

cardBase hashes once on module load. valueStyle only recalculates when color actually changes. In a dashboard where 20 cards re-render every few seconds on incoming data, this pattern eliminates the bulk of unnecessary DOM writes.

Advanced: SSR style extraction in Next.js

jsx
// pages/_document.js import Document, { Html, Head, Main, NextScript } from 'next/document'; import { ServerStyleSheet } from 'styled-components'; export default class MyDocument extends Document { static async getInitialProps(ctx) { const sheet = new ServerStyleSheet(); const originalRenderPage = ctx.renderPage; try { ctx.renderPage = () => originalRenderPage({ enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), }); const initialProps = await Document.getInitialProps(ctx); return { ...initialProps, // Styles injected into <head> before client-side hydration styles: [initialProps.styles, sheet.getStyleElement()], }; } finally { sheet.seal(); } } }

Without ServerStyleSheet, Next.js sends HTML with the correct DOM structure but no styled-components class definitions. React hydrates, styles inject, and there is a visible flash. With this setup, the server collects every style rule during renderPage, ships them inline in <head>, and the client receives a fully styled document from the first byte.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?