# How useTheme Manages Light/Dark Mode and Persists Preferences in pi-web

> Learn how pi-web's useTheme hook manages light/dark mode using useSyncExternalStore, persists preferences to localStorage, and enables smooth View Transitions for a better user experience.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: how-to-guide
- Published: 2026-07-31

---

**The `useTheme` hook in pi-web uses React's `useSyncExternalStore` to monitor the `dark` class on the document root, persists user preferences to `localStorage` under the key `pi-theme`, and optionally triggers smooth View Transition API animations when toggling between light and dark modes.**

The `useTheme` hook serves as the central theming engine for the pi-web React application, handling everything from initial theme detection to cross-session persistence. Located in [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts), this lightweight utility synchronizes the UI with the browser's storage and DOM while gracefully handling server-side rendering (SSR) edge cases.

## Understanding the useTheme Hook Architecture

### Theme Detection and SSR Safety

The hook determines the current theme by inspecting the `dark` class on `document.documentElement`. In [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts), the `getSnapshot()` function checks `document.documentElement.classList.contains("dark")` to return either `"dark"` or `"light"` (lines 16-19).

For SSR compatibility, `getServerSnapshot()` hardcodes a fallback to `"light"` (lines 21-23). This prevents hydration mismatches and eliminates flashes of incorrect styling during initial page loads when the code executes on the server.

### External Store Subscription

To ensure components re-render when the theme changes, `useTheme` leverages React's `useSyncExternalStore`. The implementation maintains a global `Set` of listener callbacks referenced as `listeners` in the source.

The `subscribe` function adds and removes callbacks from this set (lines 9-13), allowing `useSyncExternalStore` to consume these subscriptions (lines 27-28) and trigger updates across the component tree whenever the theme toggles.

## Toggling Themes with Persistent Storage

### localStorage Persistence Strategy

When users initiate a theme change, `toggleTheme` calculates the next state using the expression `next = getSnapshot() === "dark" ? "light" : "dark"` (lines 30-32). It then immediately updates the DOM by adding or removing the `dark` class and writes the preference to `localStorage` under the key `pi-theme` (lines 34-41).

The storage operation is wrapped in a `try…catch` block (lines 39-43) to safely handle private browsing restrictions or storage quotas where `localStorage` access might throw errors.

### View Transition API Animation

For supported browsers, `toggleTheme` creates a circular reveal animation anchored at the click coordinates (or screen center if no coordinates are provided). The implementation detects View Transition API support via `supportsVT` and checks for `prefers-reduced-motion` media queries (lines 47-50).

When available and permitted, the code calls `document.startViewTransition` combined with `document.documentElement.animate` to produce a smooth morphing effect between color schemes (lines 55-77). If the API is unavailable or the user prefers reduced motion, the theme switches instantly without animation.

## Implementation Examples

Here is how to implement a theme toggle button that passes click coordinates for the transition animation:

```typescript
// Example: a button that toggles the theme with a ripple effect
import { useTheme } from '@/hooks/useTheme';

export function ThemeToggleButton() {
  const { isDark, toggleTheme } = useTheme();

  const handleClick = (e: React.MouseEvent) => {
    // Pass the click coordinates to get a centered transition animation
    toggleTheme({ x: e.clientX, y: e.clientY });
  };

  return (
    <button onClick={handleClick} className="p-2 rounded">
      {isDark ? '🌙 Dark' : '☀️ Light'}
    </button>
  );
}

```

For applying theme-aware styles in components:

```typescript
// Example: applying theme‑aware styles in a component
import { useTheme } from '@/hooks/useTheme';

export function Header() {
  const { theme } = useTheme(); // "light" or "dark"

  return (
    <header className={theme === 'dark' ? 'bg-gray-800 text-white' : 'bg-white text-gray-900'}>
      <h1>pi‑web</h1>
    </header>
  );
}

```

## Summary

- **SSR-safe detection**: `useTheme` reads the `dark` class from `document.documentElement` on the client while defaulting to `"light"` during server-side rendering to prevent hydration errors.
- **External store pattern**: The hook uses `useSyncExternalStore` with a global `Set` of listeners to broadcast theme changes to all subscribed components.
- **Persistent storage**: User preferences are saved to `localStorage` under the key `pi-theme` with error handling for restricted browsing modes.
- **Smooth transitions**: Optional View Transition API support creates circular reveal animations when toggling themes, with automatic fallback for unsupported browsers or reduced-motion preferences.
- **Simple API**: The hook returns `{ theme, toggleTheme, isDark }` for reading the current state and initiating animated or instant theme switches.

## Frequently Asked Questions

### How does useTheme handle server-side rendering?

The hook provides a `getServerSnapshot()` function that returns `"light"` unconditionally (lines 21-23 in [`hooks/useTheme.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useTheme.ts)). This ensures the server-rendered HTML matches the initial client state, preventing hydration mismatches and visual flashes when React hydrates the application.

### Where does pi-web store theme preferences?

Preferences are persisted in the browser's `localStorage` under the key `pi-theme` (line 38). This allows the theme choice to survive page reloads and new sessions. The hook reads this storage during initialization to restore the user's previous selection.

### What happens if localStorage is unavailable?

The `toggleTheme` function wraps storage operations in a `try…catch` block (lines 39-43). If writing to `localStorage` fails—such as in private browsing modes or when storage quotas are exceeded—the theme still toggles in the DOM and memory, but the preference won't persist across sessions.

### Does useTheme support animation when switching themes?

Yes, when the browser supports the View Transition API and the user hasn't requested reduced motion, `toggleTheme` creates a circular reveal animation centered on the click coordinates. The implementation uses `document.startViewTransition` combined with `document.documentElement.animate` (lines 55-77) to produce the effect. If the API is unavailable or accessibility preferences require reduced motion, the theme switches instantly without animation.