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
// 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 CSSThe 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
| Aspect | CSS-in-JS (runtime) | CSS Modules | Tailwind |
|---|---|---|---|
| Scoping | Runtime hashed class names | Build-time local IDs | Utility classes |
| Dynamic styles | Native via props | Workarounds needed | Arbitrary values only |
| Bundle impact | Runtime JS (~10-50KB) | Zero runtime | ~10KB after purge |
| SSR setup | Requires extra config | Works by default | Works by default |
| When to use | Dynamic UIs, theme systems | Static components | Prototyping, 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
// 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
// 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 hydration3. Forwarding non-style props to the DOM
// 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
ThemeProviderfor per-user theme preference switching - Chakra UI: Emotion with
shouldForwardPropto 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-emotionfor 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
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 CSSThemeProvider 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
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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.