A complete theme management system for React applications with SSR support! β¨
π Overview
This implementation provides a robust theme provider that handles:
- π Dark/Light mode switching
- π₯οΈ System preference detection
- πΎ Persistent storage with localStorage
- π SSR-friendly initialization
- β‘ Real-time theme updates
- β No flash of unstyled content (FOUC)
π The Provider: ThemeProvider.tsx
Key Features:
- π§ Manages theme state (
'dark'
|'light'
|'system'
) - ποΈ Handles localStorage persistence
- π Listens to system preference changes
- β‘ Updates DOM classes dynamically
import { createContext, use, useEffect, useMemo, useState } from 'react'
import { FunctionOnce } from './lib/function-once'
export type ResolvedTheme = 'dark' | 'light'
export type Theme = ResolvedTheme | 'system'
interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
interface ThemeProviderState {
theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: 'system',
resolvedTheme: 'light',
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
const isBrowser = typeof window !== 'undefined'
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'conar.theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (isBrowser ? (localStorage.getItem(storageKey) as Theme) : defaultTheme) || defaultTheme,
)
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')
useEffect(() => {
const root = window.document.documentElement
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function updateTheme() {
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light'
setResolvedTheme(systemTheme)
root.classList.add(systemTheme)
return
}
setResolvedTheme(theme as ResolvedTheme)
root.classList.add(theme)
}
mediaQuery.addEventListener('change', updateTheme)
updateTheme()
return () => mediaQuery.removeEventListener('change', updateTheme)
}, [theme])
const value = useMemo(() => ({
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}), [theme, resolvedTheme, storageKey])
return (
<ThemeProviderContext value={value}>
<FunctionOnce param={storageKey}>
{(storageKey) => {
const theme: string | null = localStorage.getItem(storageKey)
if (
theme === 'dark'
|| (
(theme === null || theme === 'system')
&& window.matchMedia('(prefers-color-scheme: dark)').matches
)
) {
document.documentElement.classList.add('dark')
}
}}
</FunctionOnce>
{children}
</ThemeProviderContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTheme() {
const context = use(ThemeProviderContext)
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider')
return context
}
π οΈ FunctionOnce Utility: /lib/function-once.ts
π Purpose: Executes JavaScript code once during SSR hydration
- β
Uses
ScriptOnce
from TanStack Router - π Prevents hydration mismatches
- π¨ Ensures theme is applied before first paint
import { ScriptOnce } from '@tanstack/react-router'
export function FunctionOnce<T = unknown>({ children, param }: { children: (param: T) => unknown, param?: T }) {
return (
<ScriptOnce>
{`(${children.toString()})(${JSON.stringify(param)})`}
</ScriptOnce>
)
}
ποΈ Implementation Flow
- π Initial Load: Checks localStorage or defaults to system preference
-
π Event Listening: Monitors system theme changes via
matchMedia
-
π¨ DOM Updates: Adds/removes CSS classes on
documentElement
- πΎ Persistence: Saves theme choice to localStorage
-
π Hydration: Uses
FunctionOnce
to prevent SSR/client mismatches
π― Example Usage: /src/routes/__root.tsx
Integration highlights:
- π App-wide theme provider wrapping
- π§ Integration with TanStack Router
- π¨ Global CSS and styling setup
- π οΈ Development tools integration
import type { QueryClient } from '@tanstack/react-query'
import { Toaster } from '@conar/ui/components/sonner'
import appCss from '@conar/ui/globals.css?url'
import { useMountedEffect } from '@conar/ui/hookas/use-mounted-effect'
import { ThemeProvider } from '@conar/ui/theme-provider'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { createRootRouteWithContext, HeadContent, Outlet, Scripts, useRouterState } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { ErrorPage } from '~/error-page'
import { getLatestReleaseOptions, getRepoOptions, getUsersCountOptions } from '~/queries'
import { seo } from '~/utils/seo'
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan()
})
}
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
beforeLoad: async ({ context }) => {
if (typeof window !== 'undefined') {
context.queryClient.prefetchQuery(getRepoOptions)
context.queryClient.prefetchQuery(getLatestReleaseOptions)
context.queryClient.prefetchQuery(getUsersCountOptions)
}
},
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
...seo({
title: 'Conar.app - AI-powered connections management tool',
description: 'AI-powered tool that makes database operations easier. Built for PostgreSQL. Modern alternative to traditional database management tools.',
image: '/og-image.png',
}),
{ name: 'apple-mobile-web-app-title', content: 'Conar' },
],
links: [
{ rel: 'stylesheet', href: appCss },
{ rel: 'icon', type: 'image/png', href: '/favicon-96x96.png', sizes: '96x96' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'shortcut icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' },
{ rel: 'manifest', href: '/site.webmanifest' },
],
scripts: [
{
defer: true,
src: 'https://assets.onedollarstats.com/stonks.js',
...(import.meta.env.DEV ? { 'data-debug': 'conar.app' } : {}),
},
],
}),
component: RootComponent,
errorComponent: props => <ErrorPage {...props} />,
})
function RootComponent() {
const { queryClient } = Route.useRouteContext()
const pathname = useRouterState({ select: state => state.location.pathname })
useMountedEffect(() => {
window.scrollTo({
top: 0,
})
}, [pathname])
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="bg-gray-100 dark:bg-neutral-950">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Outlet />
<ReactQueryDevtools buttonPosition="bottom-left" />
</ThemeProvider>
</QueryClientProvider>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<Scripts />
</body>
</html>
)
}
π Benefits & Features
β Production-Ready Advantages
- No FOUC (Flash of Unstyled Content) - Theme applied before first paint
- Smooth SSR Hydration - No client/server mismatches
- Automatic System Detection - Respects user's OS preference
- Persistent Preferences - Remembers user's choice across sessions
- TypeScript Support - Full type safety throughout
- Performance Optimized - Minimal re-renders with useMemo
π― Usage Tips
-
Customization: Change
storageKey
prop to match your app's naming -
Styling: Use CSS classes
.dark
and.light
for theme-specific styles -
Integration: Works seamlessly with Tailwind's
dark:
modifier -
Testing: The
suppressHydrationWarning
prevents dev warnings during SSR
π Quick Setup
- Install dependencies:
@tanstack/react-router
- Copy the
ThemeProvider.tsx
andFunctionOnce.ts
files - Wrap your app with
<ThemeProvider>
- Use
useTheme()
hook in components - Add CSS classes for
.dark
and.light
themes
π References
This theme provider is battle-tested in production and handles all the tricky edge cases of SSR theme management! πβ¨
Top comments (0)