Here you can have an example of what I've done with Next.js and Material UI (5) to:
- Have 2 themes available: lightTheme and darkTheme.
- Have a ThemeSwitcherButton component so we can swtich between both themes.
- Create a new ThemeProvider and ThemeContext to store the selected theme mode value, provide access to read and change it.
- Store the preference of the user on Local Storage using a useLocalStorage hook.
- Load the theme mode reading from the browser preference if there's no storage value, using the Material UI useMediaQuery hook.
I'm using Typescript, but it doesn't matter if you use plain JavaScript
Create the 2 themes needed:
We'll have 2 files to modify the specific properties independently.
darkTheme.ts
import { createTheme } from '@mui/material/styles'
const darkTheme = createTheme({
palette: {
mode: 'dark',
},
})
export default darkTheme
lightTheme.ts
import { createTheme } from '@mui/material/styles'
const lightTheme = createTheme({
palette: {
mode: 'light',
},
})
export default lightTheme
Create the switcher button:
The important thing here is the value and function retrieved from the context, the button style or icon can be anything.
interface ThemeSwitcherButtonProps extends IconButtonProps { }
const ThemeSwitcherButton = ({ ...rest }: ThemeSwitcherButtonProps) => {
const { themeMode, toggleTheme } = useThemeContext()
return (
<Tooltip
title={themeMode === 'light' ? `Switch to dark mode` : `Switch to light mode`}
>
<IconButton
{...rest}
onClick={toggleTheme}
>
{themeMode === 'light' ? <DarkModeOutlined /> : <LightModeRounded />}
</IconButton>
</Tooltip>
)
}
export default ThemeSwitcherButton
Create the ThemeContext, ThemeProvider, and useThemeContext:
We use useMediaQuery from material ui library to check the preference mode of the browser. This hook works with client rendering and ssr.
We also use useLocalStorage hook to save the state in the local storage so it's persisted.
We wrap the original Material UI ThemeProvider (renamed as MuiThemeProvider) with this new Provider, so then in the _app file only one Provider is needed
ThemeContext.tsx
import { createContext, ReactNode, useContext } from 'react'
import { ThemeProvider as MuiThemeProvider, useMediaQuery } from '@mui/material'
import lightTheme from '@/themes/light'
import darkTheme from '@/themes/dark'
import useLocalStorage from '@/react/hooks/useLocalStorage'
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
type ThemeMode = 'light' | 'dark'
interface ThemeContextType {
themeMode: ThemeMode
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType)
const useThemeContext = () => useContext(ThemeContext)
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const isDarkOS = useMediaQuery(DARK_SCHEME_QUERY)
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>('themeMode', isDarkOS ? 'light' : 'dark')
const toggleTheme = () => {
switch (themeMode) {
case 'light':
setThemeMode('dark')
break
case 'dark':
setThemeMode('light')
break
default:
}
}
return (
<ThemeContext.Provider value={{ themeMode, toggleTheme }}>
<MuiThemeProvider theme={themeMode === 'light' ? lightTheme : darkTheme}>
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
)
}
export {
useThemeContext,
ThemeProvider
}
_app.tsx
export default function MyApp(props: MyAppProps) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props
const getLayout = Component.getLayout ?? ((page) => page)
return (
<CacheProvider value={emotionCache}>
<Head>
<meta name="viewport" content="initial-scale=1, width=device-width" />
</Head>
<ThemeProvider>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</CacheProvider>
)
}
Create useLocalStorage hook:
I took the source from here, and modified it a little bit so could work properly with Next.js due to ssr and client rendering mismatches.
The useLocalStorage also uses another useEventListener hook, to synchronize the changes on the value among all other tabs opened.
useLocalStorage.tsx
// edited from source: https://usehooks-ts.com/react-hook/use-local-storage
// to support ssr in Next.js
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import useEventListener from '@/react/hooks/useEventListener'
type SetValue<T> = Dispatch<SetStateAction<T>>
function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
// Read local storage the parse stored json or return initialValue
const readStorage = (): T => {
if (typeof window === 'undefined') {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
return item ? (parseJSON(item) as T) : initialValue
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error)
return initialValue
}
}
// Persists the new value to localStorage.
const setStorage: SetValue<T> = value => {
if (typeof window == 'undefined') {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`,
)
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(state) : value
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue))
// We dispatch a custom event so every useLocalStorage hook are notified
window.dispatchEvent(new Event('local-storage'))
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error)
}
}
// State to store the value
const [state, setState] = useState<T>(initialValue)
// Once the component is mounted, read from localStorage and update state.
useEffect(() => {
setState(readStorage())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
setStorage(state)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state])
const handleStorageChange = () => {
setState(readStorage())
}
// this only works for other documents, not the current one
useEventListener('storage', handleStorageChange)
// this is a custom event, triggered in writeValueToLocalStorage
// See: useLocalStorage()
useEventListener('local-storage', handleStorageChange)
return [state, setState]
}
export default useLocalStorage
// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined {
try {
return value === 'undefined' ? undefined : JSON.parse(value ?? '')
} catch (error) {
console.log('parsing error on', { value })
return undefined
}
}
The useEventListener.tsx is exactly the same as is in the web.
The code
All of the code of this example can be found in my github repository that can be used as a starting point for a project with Typescript, Nextjs and Material UI.
You can also try a working example here.