Use CSS Variables instead of React Context
I've been riding the CSS-in-JS train for years (I was even a significant contributor to the "movement"). It's awesome. I've never been so productive working with CSS than when I added the power of JavaScript to it.
I'm still a fan of CSS-in-JS, and in recent years, the CSS spec has evolved and improved a lot and modern browsers have too (unfortunately, Internet Explorer is NOT a modern browser in this or any context). Often I'd use a
ThemeProvider
(like those found in emotion), but turns out there aren't a whole lot of advantages to that kind of component for many use cases and there are several disadvantages.
Let's look at a simple example of "Dark Mode" and compare differences in API (developer experience) and performance (user experience). We'll keep the example simple, and in both the before/after, we'll be using emotion's
styled
utility. Keep in mind that with the
ThemeProvider
you can consume the values using the
useTheme
hook, with a styled component, or with the
css
prop. With CSS Variables, you can get the values in your CSS with
var(--css-variable-name)
and in your JavaScript using
getComputedStyle(element).getPropertyValue('--css-variable-name')
(which you really don't need to do...)
Ok, let's look at some code. Here's an approach to using emotion's
ThemeProvider
:
import * as React from 'react'
import styled from '@emotion/styled'
import {ThemeProvider} from 'emotion-theming'
const themes = {
light: {
colors: {
primary: 'deeppink',
background: 'white',
},
},
dark: {
colors: {
primary: 'lightpink',
background: 'black',
},
},
}
const PrimaryText = styled.div(({theme}) => ({
padding: 20,
color: theme.colors.primary,
backgroundColor: theme.colors.background,
}))
function ThemeToggler({theme, onClick}) {
const nextTheme = theme === 'light' ? 'dark' : 'light'
return (
<button onClick={() => onClick(nextTheme)}>
Change to {nextTheme} mode
</button>
)
}
function App() {
const [theme, setTheme] = React.useState('light')
return (
<ThemeProvider theme={themes[theme]}>
<PrimaryText>This text is the primary color</PrimaryText>
<ThemeToggler
theme={theme}
onClick={(nextTheme) => setTheme(nextTheme)}
/>
</ThemeProvider>
)
}
export default App
What's cool about this is it's "just JavaScript" so you get all the benefits of variables etc. But we're not really doing all that much with this other than passing it around through the ThemeProvider. (To be clear, the way the ThemeProvider works is it uses React's Context API to make the theme accessible to all emotion components without having to pass props all over the place).
Let's compare this with the CSS Variables approach. But before we get to that, I need to mention that there's no "ThemeProvider" for this. Instead, we define the variables in regular CSS that will get applied based on a
data
attribute we apply to the
body
. So here's that css file:
body[data-theme='light'] {
--colors-primary: deeppink;
--colors-background: white;
}
body[data-theme='dark'] {
--colors-primary: lightpink;
--colors-background: black;
}
Alright, so with that, here's the implementation of the exact same UI:
import * as React from 'react'
import './css-vars.css'
import styled from '@emotion/styled'
const PrimaryText = styled.div({
padding: 20,
color: 'var(--colors-primary)',
backgroundColor: 'var(--colors-background)',
})
function ThemeToggler() {
const [theme, setTheme] = React.useState('light')
const nextTheme = theme === 'light' ? 'dark' : 'light'
React.useEffect(() => {
document.body.dataset.theme = theme
}, [theme])
return (
<button onClick={() => setTheme(nextTheme)}>
Change to {nextTheme} mode
</button>
)
}
function App() {
return (
<div>
<PrimaryText>This text is the primary color</PrimaryText>
<ThemeToggler />
</div>
)
}
export default App
Let's first compare what it's like to use these values:
// ThemeProvider:
const PrimaryText = styled.div(({theme}) => ({
padding: 20,
color: theme.colors.primary,
backgroundColor: theme.colors.background,
}))
// CSS Variables:
const PrimaryText = styled.div({
padding: 20,
color: 'var(--colors-primary)',
backgroundColor: 'var(--colors-background)',
})
There's not really much of a difference from a DX (development experience) standpoint here. One point for the CSS Variables approach is not having to create a function that accepts the theme and returning styles (and no need to even learn about that API).
One point for the
ThemeProvider
approach is if you're using TypeScript you could get type safety on your theme... But ummm...
Check this out
:
// src/theme.js
const theme = {
colors: {
primary: 'var(--colors-primary)',
background: 'var(--colors-background)',
},
}
export {theme}
// anywhere-else.js
import {theme} from 'theme'
const PrimaryText = styled.div({
padding: 20,
color: theme.colors.primary,
backgroundColor: theme.colors.background,
})
BOOM. Static typing-friendly.
Either way, that's really the only significant DX difference. Let's consider the UX (user experience) difference. Why don't you play around with it:
You'll notice there's not really any observable UX difference, and there's not for this simple example. But why don't you try this with me:
Here's what I see:
ThemeProvider:
CSS Variables:
I don't want you to get hung up on the number of milliseconds to rerender. This isn't a controlled benchmark (we're in React's dev mode for one thing). The thing I want you to consider is how many components needed to re-render for this change. Let's consider the
ThemeProvider
approach first. The main reason for this is the way we've structured our state (
we could restructure things
and improve it a little bit). But even if we restructured things, when the theme changes,
every
emotion component needs to be re-rendered to account for the theme change.