Adding Light/Dark Mode to a Material-UI App Without the Rerender Trap
Dark mode in Material-UI is twenty lines of code — until the re-render flicker shows up. Here's the memoized ThemeProvider pattern, plus the pitfalls that break it.
13 Nov 2025, 21:33 UTC

Your users want a dark mode toggle. In Material-UI, the naive version takes about twenty lines: create two themes, wrap the app in ThemeProvider, flip a piece of state. It works — and then, a few months later, someone notices the whole app re-renders on every toggle, the flicker is visible, and the theme object is being recreated on every render anyway.
The useful takeaway: dark mode in MUI is easy, but doing it well means treating the theme object as immutable, memoizing it, and keeping the toggle state high enough that the re-render is intentional rather than accidental. This post walks through that version. Examples assume MUI v5 (@mui/material) with React 18; the API is the same in v6, but verify against your installed version.
How ThemeProvider and createTheme fit together
createTheme takes a plain options object — palette, typography, shape, component overrides — and returns a complete theme object with all of MUI's defaults filled in. ThemeProvider puts that object into React context. Every MUI component below it reads values from context: a Button with color="primary" resolves theme.palette.primary.main at render time.
The key detail for dark mode is palette.mode. Setting it to 'dark' doesn't just flip a flag — MUI derives appropriate defaults for background.default, background.paper, and text colors from it. You get a coherent dark palette for free, then override only what you dislike.
A worked example: a memoized toggle
Here is the pattern that avoids the common pitfalls. Run it anywhere in your React app entry point (e.g., src/App.tsx in a Vite or Create React App project with @mui/material and @emotion/react/@emotion/styled installed):
import { useMemo, useState } from 'react';
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
import IconButton from '@mui/material/IconButton';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
export default function App() {
const [mode, setMode] = useState<'light' | 'dark'>('light');
// Memoize so the theme object identity only changes when mode changes.
const theme = useMemo(
() =>
createTheme({
palette: {
mode,
primary: { main: mode === 'light' ? '#1565c0' : '#90caf9' },
},
}),
[mode]
);
return (
<ThemeProvider theme={theme}>
<CssBaseline /> {/* applies background.default to <body> */}
<IconButton
aria-label="toggle color mode"
onClick={() => setMode((m) => (m === 'light' ? 'dark' : 'light'))}
>
{mode === 'light' ? <DarkModeIcon /> : <LightModeIcon />}
</IconButton>
{/* rest of the app */}
</ThemeProvider>
);
}Three things in this snippet are doing quiet work:
useMemoaroundcreateTheme:createThemeis not cheap, and a new object identity on every render would force every context consumer to re-render even when nothing changed. Memoization keeps the identity stable between toggles.CssBaseline: without it, the<body>background stays white in dark mode because MUI components style themselves, not the page.CssBaselineappliestheme.palette.background.defaultglobally.- State at the top, not in a leaf: the toggle re-renders the tree under
ThemeProvideronce, deliberately. That's the cost of switching; the mistake is paying it on every unrelated render.
Styling your own components against the mode
MUI components adapt automatically. Your custom components should read from the theme rather than hard-coding colors, or they'll look wrong in one mode. Two supported options:
// Option 1: sx prop — fine for one-off layout, reads the theme
<Box sx={{ bgcolor: 'background.paper', color: 'text.primary' }} />
// Option 2: styled() — better for reusable components
const Card = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
padding: theme.spacing(2),
}));Prefer palette tokens like background.paper and text.primary over literal hex values. That is the whole point of the design system: when the mode flips, token-based styles follow it for free.
The trade-offs worth knowing
Toggling re-renders the tree. Changing the theme object invalidates context for every consumer. In most apps this is a one-frame cost on a user-initiated click and is fine. If profiling shows it isn't, the mitigations are keeping the provider as high as possible (so nothing above it re-renders), memoizing expensive subtrees, and not recreating the theme object unnecessarily. Verify with React DevTools Profiler: toggle the mode and check that only one commit occurs and that memoized subtrees are skipped.
Never mutate the theme. MUI relies on object identity. If you do theme.palette.mode = 'dark' in place, consumers see the same object reference and nothing updates — or worse, updates inconsistently. Always build a new theme via createTheme.
sx-prop sprawl. The sx prop is convenient, but if every component hard-codes sx={{ color: '#333' }}, dark mode will never look right and you've bypassed the theme. Reserve sx for layout and spacing; put colors in the palette or in styled components.
Persistence and system preference are on you. MUI does not store the user's choice. The standard approach is to initialize state from localStorage, falling back to window.matchMedia('(prefers-color-scheme: dark)'), and write back on toggle. Test the no-JS and first-visit cases so users don't get a flash of the wrong theme.
How to check it actually works
After wiring up the toggle, verify three things: click it and confirm <body>'s computed background-color in DevTools matches background.default for the active mode; confirm a Button color="primary" changes without any local overrides; and run one Profiler session to confirm a single commit per toggle. If all three hold, your theming is token-driven and your render behavior is intentional.
Start with the memoized two-theme setup above, keep colors out of sx, and add persistence once the basics are solid. That ordering gets you a dark mode that survives contact with a real codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.