A single string is rarely enough. A theme is a set of related values, and the natural move is to publish an object. Nothing about the API changes: value takes any JavaScript value, and useContext returns whatever the nearest provider passed.
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }
</script>
<script type="text/babel" data-type="module">
import React, { createContext, useContext } from 'react';
import { createRoot } from 'react-dom/client';
const themes = { light: { foreground: '#000000', background: '#eeeeee' },
dark: { foreground: '#ffffff', background: '#222222' } };
const ThemeContext = createContext(themes.light);
const App = () => <ThemeContext value={themes.dark}><Toolbar /></ThemeContext>;
const Toolbar = () => <div style={{ padding: '12px' }}><ThemedButton /></div>;
function ThemedButton() {
const t = useContext(ThemeContext);
return <button style={{ background: t.background, color: t.foreground, border: 0,
padding: '10px 16px', fontSize: '16px' }}>Styled by theme context</button>;
}
createRoot(document.getElementById('root')).render(<App />);
</script>
Toolbar neither receives nor forwards anything, and ThemedButton would render identically ten levels deeper. Swapping themes.dark for themes.light on the provider restyles the whole subtree.
The detail that matters is identity. React 7,897 compares the old and new value with Object.is and re-renders every reader when they differ. themes.dark is created once at module scope, so it is the same object on every render and readers are left alone. Write it inline — value={{ foreground: '#fff', background: '#222' }} — and a brand-new object is allocated each time App renders, so every reader re-renders even though nothing about the theme changed. Context Performance Pitfalls covers the fix.