14.
Context & App-Level Preferences
Written by Eli Ganim
In the previous chapter, you tamed the network. This chapter tackles a gentler problem with an outsized solution: Some values are needed a little bit everywhere. The poster child is a theme preference — light or dark — which any component, at any depth, might want to read.
Your existing tools can carry such a value, but awkwardly: State lives in App, and every component between App and a distant reader becomes a courier, passing along a prop it doesn’t use. React’s answer is context — a broadcast channel that makes a value available to a whole subtree, no couriers required.
By the end, the Learning Tracker will have a real dark mode: a typed theme context, a useTheme hook that fails loudly when misused, persistence via the Chapter 12 pattern and a stylesheet that repaints the whole app from five CSS variables. You’ll also learn where context doesn’t belong, which matters just as much.
The Courier Problem
Feel the pain before buying the cure. Suppose theme state lived in App the usual way — read, don’t type this:
const [theme, setTheme] = useState<'light' | 'dark'>('light')
PageHeader hosts the toggle button, so it needs both the value and a setter — two more props. Tolerable. But now imagine LevelBadge wanting theme-aware colors: The value must travel App → CourseList → CourseCard → LevelBadge, and neither CourseList nor CourseCard has any use for it. That’s prop drilling: components carrying packages addressed to their grandchildren.
Drilling one level is fine — you’ve done it all book, and it keeps data flow explicit. Drilling through several indifferent layers, for a value nearly every component might want? That’s the specific ache context exists to cure:
A provider component wraps a subtree and offers a value; any component inside the boundary can read it directly. The value teleports past the couriers.
Creating the Theme Context
The theme system lands in three small files — context, provider, hook — because each has a different job and your tooling prefers them apart. Start with the channel itself. Create src/ThemeContext.ts:
import { createContext } from 'react'
export type Theme = 'light' | 'dark'
export type ThemeContextValue = {
theme: Theme
toggleTheme: () => void
}
export const ThemeContext =
createContext<ThemeContextValue | null>(null)
createContext builds the channel, and its argument is the value consumers see when no provider is above them — deliberately null here. That’s a design choice, not laziness: A fake default, like a do-nothing toggleTheme, would let a misconfigured app limp along broken; null makes the mistake detectable, and the hook you’ll write shortly turns it into a clear error.
The value’s type bundles the current theme with the function that changes it — the same state-plus-updater duo you’ve been passing as props since Chapter 11, now traveling as one broadcast. Save; expect no visible change, since a channel with no broadcaster and no listeners is just potential.
Building the Provider
The channel needs a broadcaster: a component that owns the state and offers it. Create src/ThemeProvider.tsx:
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { ThemeContext } from './ThemeContext.ts'
import type { Theme } from './ThemeContext.ts'
function loadTheme(): Theme {
const stored = localStorage.getItem('learning-tracker-theme')
return stored === 'dark' ? 'dark' : 'light'
}
type ThemeProviderProps = {
children: ReactNode
}
export function ThemeProvider({
children,
}: ThemeProviderProps) {
const [theme, setTheme] = useState(loadTheme)
useEffect(() => {
localStorage.setItem('learning-tracker-theme', theme)
document.documentElement.dataset.theme = theme
}, [theme])
function toggleTheme() {
setTheme((current) =>
current === 'light' ? 'dark' : 'light',
)
}
return (
<ThemeContext value={{ theme, toggleTheme }}>
{children}
</ThemeContext>
)
}
Every line is a Chapter 12 rerun with a new purpose. The loader reads storage — a two-value preference needs no JSON, and anything unexpected falls back to 'light' — and arrives through a lazy initializer.
The effect synchronizes two outside systems at once: storage, for next visit, and document.documentElement.dataset.theme, which stamps data-theme="dark" onto the <html> element — the styling hook your CSS will grab shortly. The page root is outside React’s tree, which is exactly why touching it belongs in an effect.
The return is the new part: Rendering the context object as a component with a value prop makes this subtree a provider — React 19 syntax, replacing the older <ThemeContext.Provider> wrapper you’ll still meet in existing codebases. Whatever the caller nested arrives via children and renders inside the boundary.
Now choose the boundary. Theme is app-wide, so the provider belongs at the very top. In src/main.tsx, import it below the App import:
import { ThemeProvider } from './ThemeProvider.tsx'
A named export — the file’s other residents stay put. And wrap the app:
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
Everything the app renders now sits inside the broadcast. Save — no visible change yet, since nothing consumes the channel.
Reading Context Safely
Consumers could call React’s useContext directly, but every one of them would face the same null question. Centralize the answer once. Create src/useTheme.ts:
import { useContext } from 'react'
import { ThemeContext } from './ThemeContext.ts'
import type { ThemeContextValue } from './ThemeContext.ts'
export function useTheme(): ThemeContextValue {
const value = useContext(ThemeContext)
if (value === null) {
throw new Error(
'useTheme must be used inside ThemeProvider',
)
}
return value
}
useContext reads the nearest provider’s value — or the null default if a component calls this outside the boundary. The guard converts that silent misconfiguration into an immediate, named error pointing at the fix, and as a bonus, every caller receives a clean ThemeContextValue with no null-checking of their own. Expect this guarded-hook pattern wherever a context is meaningless without its provider — as here; a context with a genuinely useful standalone default can skip the ceremony and just ship that default.
Time for a consumer. The toggle lives in the page header — a component whose props say nothing about themes, which is the whole point. In src/components/PageHeader.tsx, add two imports below the logo line:
import Button from './Button.tsx'
import { useTheme } from '../useTheme.ts'
The header needs a button to render and the hook to power it — note the ../ climb for the hook, which lives at the src root. Read the channel at the top of the component:
const { theme, toggleTheme } = useTheme()
No new props, no couriers — the header simply asks. Then add the toggle after the favorites count, before </header>:
<Button
label="Dark mode"
variant="ghost"
onClick={toggleTheme}
pressed={theme === 'dark'}
/>
A proper Chapter 8 toggle: stable label, aria-pressed reflecting the state. Save and click it:
The button’s pressed styling flips and DevTools shows data-theme="dark" on <html>… and the page itself doesn’t change at all, because no CSS reads the stamp yet. State without pixels — let’s fix the pixels.
Teaching the Stylesheet About Themes
Hard-coded colors can’t follow a preference; CSS custom properties can. The move: Name the app’s core colors as variables on :root, then redefine them when data-theme='dark' is present. Replace the entire contents of src/index.css with:
:root {
color-scheme: light;
--page-bg: #f4f6f8;
--text: #22272e;
--text-muted: #57606a;
--surface: #ffffff;
--border: #d8dee6;
}
:root[data-theme='dark'] {
color-scheme: dark;
--page-bg: #14181d;
--text: #e8ebef;
--text-muted: #9aa4b1;
--surface: #1d232b;
--border: #39424e;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
background: var(--page-bg);
color: var(--text);
line-height: 1.5;
}
Five variables cover the app’s chrome: page background, two text tones, panel surfaces and borders. The :root[data-theme='dark'] block re-declares them for dark — the Chapter 6 trick of CSS keyed off an attribute, scaled to the whole page — and color-scheme flips so scrollbars and form controls follow. body now paints with var(...) lookups instead of literals.
src/App.css still holds literals, and they’re systematic enough for four find-and-replace passes. Across the whole file, replace:
- Every
background: #ffffff;withbackground: var(--surface); - Every
border: 1px solid #d8dee6;withborder: 1px solid var(--border); - Every
border: 1px solid #b7c0cc;— the form controls — withborder: 1px solid var(--border); - Every
color: #57606a;withcolor: var(--text-muted);
That’s cards, panels, form fields, headers and every muted caption, all re-based onto the variables. The brand colors — badges, buttons, banners, status pills — stay literal deliberately: They’re identity, not chrome.
Deliberate doesn’t mean free, though. A handful of fixed foregrounds that read fine on light fail hard on dark — Chapter 6’s contrast discipline applies to both themes — so give them dark-mode companions. First, make the pressed-button style self-sufficient by adding one line inside the existing .button[aria-pressed='true'] rule:
color: #ffffff;
Pressed buttons no longer borrow text color from their variant, so they stay readable whatever surrounds them. Then add the dark-only corrections at the bottom of src/App.css:
:root[data-theme='dark'] .featured-banner {
color: #6a4a00;
}
:root[data-theme='dark'] .category {
color: var(--text-muted);
}
:root[data-theme='dark'] .button.ghost {
color: #9db9e8;
border-color: #9db9e8;
}
:root[data-theme='dark'] .field-error {
color: #f1959b;
}
:root[data-theme='dark'] .success-note {
color: #7fd39a;
}
:root[data-theme='dark'] .in-plan-note {
color: #9db9e8;
}
Each rule pairs a keep-its-brand background with a foreground that passes contrast on it: The amber banner gets amber-dark text, ghost buttons and notes brighten toward readable blues and greens, and the category eyebrow leans on the dark palette’s muted tone. The light theme ignores every one of these. Save and check the browser:
Nothing changed — the variables’ light values are the old literals. Now click Dark mode:
The whole page — surfaces, text, borders, form controls — crosses over in one click, and the button sits pressed with its star. Reload: still dark, courtesy of the provider’s effect. Toggle back and forth and appreciate the division of labor: React owns one word of state, CSS owns every pixel of consequence.
What Context Is — and Isn’t — For
Context is so pleasant to use that the temptation is to put everything in it. Resist, because each kind of app data already has a better home:
Local UI state stays in components; domain facts like favorites and the plan live in their lifted owner, flowing through props that document who uses what; server data arrives through fetching hooks. Context earns its keep only for genuinely cross-cutting values — preferences and identity that nearly everything reads and almost nothing changes: theme, language, the signed-in user.
Two operational notes complete the picture. Every consumer re-renders when the provider’s value changes — cheap for a rare theme flip, costly if you broadcast fast-changing data. And boundaries can be deliberate: A provider doesn’t have to wrap the whole app, only the subtree that needs the value.
Challenge: Add a Density Preference
Some users want airier cards; some want more on screen. Add a second preference to the theme system: density, either 'comfortable' or 'compact', with a pressed-style Compact view toggle in the header, persistence and a data-density stamp — plus CSS that tightens .card padding and the catalog gap when compact.
A few hints:
- Follow the theme through all three files: a
Densitytype and two new value properties in the context, state-plus-effect-plus-toggle in the provider, nothing at all in the hook. - The provider’s value object grows to four properties; TypeScript will walk you through every consumer.
- One attribute selector —
:root[data-density='compact']— carries all the CSS.
You’ll find a complete solution in the challenge folder of this chapter’s materials.
Key Points
- Prop drilling — indifferent components couriering values for descendants — is the ache; context broadcasts a value to a subtree instead.
-
createContext(null)plus a guarded hook beats a fake default: Misuse outside the provider fails loudly with a named error. - A provider component owns the state and renders
<SomeContext value={...}>around its children — React 19’s direct syntax for the older.Providerwrapper. - Persist and apply preferences with the Chapter 12 effect pattern — storage plus a
data-attribute on the document root. - Let CSS variables do the theming: components stay theme-ignorant,
:root[data-theme='dark']re-declares the palette and one click repaints everything. - Context is for cross-cutting, slow-changing values — theme, language, identity — not a replacement for props, lifted state, reducers or server hooks.
- Every consumer re-renders on value change; broadcast accordingly.
Where to Go From Here?
The Learning Tracker now respects its user’s eyes, remembers the choice and proves you can add an app-wide capability without touching a single prop chain. Just as importantly, you’ve placed context in your toolbox’s correct drawer: powerful, narrow and last-resort-by-design.
One big structural step remains in Part III. The app is still a single scrolling page — catalog, plan and form all stacked — and real apps have places: a catalog page, a details page per course, a My Learning page, each with its own URL you can bookmark and share. Chapter 15 brings in React Router and turns the Learning Tracker into a properly navigable application.