[MCH-FE] refactor: Improve ThemeContext with useSyncExternalStore

- Replace useState+useEffect with useMemo for resolvedTheme
- Use useSyncExternalStore for tracking system theme changes
- Use useLayoutEffect for DOM mutations (applying dark class)
- Add useCallback and useMemo for performance optimization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 02:28:26 -06:00
parent 777693c7dd
commit 3ee915f001

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect } from 'react'; import { createContext, useContext, useState, useLayoutEffect, useMemo, useCallback, useSyncExternalStore } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system'; type Theme = 'light' | 'dark' | 'system';
@ -31,64 +31,61 @@ function getStoredTheme(): Theme {
return 'system'; return 'system';
} }
// Subscribe to system theme changes
function subscribeToSystemTheme(callback: () => void) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', callback);
return () => mediaQuery.removeEventListener('change', callback);
}
function getSystemThemeSnapshot(): 'light' | 'dark' {
return getSystemTheme();
}
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getStoredTheme); const [theme, setThemeState] = useState<Theme>(getStoredTheme);
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
// Resolve the actual theme (handles 'system' preference) // Use useSyncExternalStore to track system theme changes
useEffect(() => { const systemTheme = useSyncExternalStore(
const resolved = theme === 'system' ? getSystemTheme() : theme; subscribeToSystemTheme,
setResolvedTheme(resolved); getSystemThemeSnapshot,
() => 'light' as const // Server snapshot
);
// Apply class to document // Compute resolved theme from theme preference and system theme
const resolvedTheme = useMemo<'light' | 'dark'>(() => {
return theme === 'system' ? systemTheme : theme;
}, [theme, systemTheme]);
// Apply class to document - useLayoutEffect is appropriate for DOM mutations
useLayoutEffect(() => {
const root = document.documentElement; const root = document.documentElement;
if (resolved === 'dark') { if (resolvedTheme === 'dark') {
root.classList.add('dark'); root.classList.add('dark');
} else { } else {
root.classList.remove('dark'); root.classList.remove('dark');
} }
}, [theme]); }, [resolvedTheme]);
// Listen for system theme changes const setTheme = useCallback((newTheme: Theme) => {
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
const root = document.documentElement;
if (newTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme); setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme); localStorage.setItem(STORAGE_KEY, newTheme);
}; }, []);
const toggleTheme = () => { const toggleTheme = useCallback(() => {
const newTheme = resolvedTheme === 'light' ? 'dark' : 'light'; const newTheme = resolvedTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme); setTheme(newTheme);
}; }, [resolvedTheme, setTheme]);
const value = useMemo(() => ({
theme,
resolvedTheme,
setTheme,
toggleTheme,
}), [theme, resolvedTheme, setTheme, toggleTheme]);
return ( return (
<ThemeContext.Provider <ThemeContext.Provider value={value}>
value={{
theme,
resolvedTheme,
setTheme,
toggleTheme,
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );