user17786777
user17786777

Reputation:

Material ui dark mode reset when page refreshing?

I am using mui5(Material Ui) in my nextjs application. I am trying to implementing dark mode. All are going well. I want a feature that if any user toggle the dark mode then it will be saved in local-storage. Then when I refresh the page, It automatically getting value from local-storage and active dark or light mode according to value from local-storage. If user first come to the site then it should active automatically system preference mode. I mean If there are no value in the local-storage then it should active automatically system preference mode. How can I do that.

Here is my code-

_app.js

export default function MyApp(props) {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
  const [mode, setMode] = React.useState("light");
  const colorMode = React.useMemo(
    () => ({
      // The dark mode switch would invoke this method
      toggleColorMode: () => {
        setMode((prevMode) =>
          prevMode === 'light' ? 'dark' : 'light',
        );
      },
    }),
    [],
  );

  // Update the theme only if the mode changes
  const muiTheme = React.useMemo(() => createTheme(theme(mode)), [mode]);
  return (
    <ColorModeContext.Provider value={colorMode}>
      <CacheProvider value={emotionCache}>
        <Head>
          <meta name="viewport" content="initial-scale=1, width=device-width" />
        </Head>

        <ThemeProvider theme={muiTheme}>
          <CssBaseline />
          <Component {...pageProps} />
        </ThemeProvider>
      </CacheProvider>
    </ColorModeContext.Provider>
  );
}

MyApp.propTypes = {
  Component: PropTypes.elementType.isRequired,
  emotionCache: PropTypes.object,
  pageProps: PropTypes.object.isRequired,
};

Toogle button-

const theme = useTheme();
const colorMode = useContext(ColorModeContext);

<FormControlLabel
   control={<MaterialUISwitch sx={{ m: 1 }} checked={theme.palette.mode === 'dark' ? false : true} />}
   label=""
   sx={{ mx: "0px" }}
   onClick={colorMode.toggleColorMode}
/>

Upvotes: 3

Views: 5239

Answers (5)

Philipp Braun
Philipp Braun

Reputation: 31

It's currently still an experimental API but MUI has fixed this with their implementation of CSS Theme variables.

From the Migration guide :

Replace ThemeProvider with CssVarsProvider

import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';

function App() {
  return <CssVarsProvider>...</CssVarsProvider>;
}

Then replace your old themes created with createTheme with extendTheme:

import { experimental_extendTheme as extendTheme} from '@mui/material/styles';

const theme = extendTheme({
  colorSchemes: {
    light: {
      palette: {
        primary: {
          main: '#ff5252',
        },
        ...
      },
    },
    dark: {
      palette: {
        primary: {
          main: '#000',
        },
        ...
      },
    },
  },
  // ...other properties
});

Toggle and localstorage logic can be removed, MUI handles this now with the useColorScheme Hook.

import {
  Experimental_CssVarsProvider as CssVarsProvider,
  experimental_extendTheme as extendTheme,
  useColorScheme,
} from '@mui/material/styles';

function ModeToggle() {
  const { mode, setMode } = useColorScheme();
  return (
    <Button
      onClick={() => {
        setMode(mode === 'light' ? 'dark' : 'light');
      }}
    >
      {mode === 'light' ? 'Turn dark' : 'Turn light'}
    </Button>
  );
}

const theme = extendTheme({
  // ...your custom theme
});

function App() {
  return (
    <CssVarsProvider theme={theme}>
      <ModeToggle />
      ...
    </CssVarsProvider>
  );
}

To prevent the flicker just place getInitColorSchemeScript() before <Main /> in your pages/_document.js

import Document, { Html, Head, Main, NextScript } from 'next/document';
import { getInitColorSchemeScript } from '@mui/material/styles';

export default class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>...</Head>
        <body>
          {getInitColorSchemeScript()}
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Upvotes: 3

Amit Rajbhandari
Amit Rajbhandari

Reputation: 393

Here is how I implemented dark mode in React MUI 5. I made a custom hook and fully supported three modes: "light," "dark," and "system."

Hope this helps someone looking for a similar answer. :)

// ColorModeContext.js

import React from "react";

export const ColorModeContext = React.createContext({
  colorMode: "system",
  toggleColorMode: () => {},
});

// useThemeSwitcher.js

import React from "react";
import { createTheme, useMediaQuery } from "@mui/material";
import theme from "configs/Theme";
import { Light, Dark } from "configs/Palette";

const useThemeSwitcher = () => {
  const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)") ? "dark" : "light"; // Get user browser scheme
  const [mode, setMode] = React.useState(localStorage.getItem("theme") || prefersDarkMode);

  const colorMode = React.useMemo(
    () => ({
      toggleColorMode: (selected) => {
        setMode(selected === "system" ? prefersDarkMode : selected);
      },
    }),
    [prefersDarkMode]
  );

  const newTheme = React.useMemo(() => {
    const themeMode = localStorage.getItem("theme") === "system" ? prefersDarkMode : mode;

    return createTheme({
      ...theme,
      palette: {
        mode: themeMode,
        ...(themeMode === "dark" ? Dark : Light), // Your custom light and dark palette
      },
    });
  }, [mode, prefersDarkMode]);

  return { newTheme, colorMode };
};

export default useThemeSwitcher;

// App.js

import React from "react";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles";
import Root from "./Root";

import { ColorModeContext } from "ColorModeContext";
import useThemeSwitcher from "hooks/useThemeSwitcher";

const App = () => {
  const { newTheme, colorMode } = useThemeSwitcher();
  return (
    /* Provide Redux store */
    <StyledEngineProvider injectFirst>
      <ColorModeContext.Provider value={colorMode}>
        <ThemeProvider theme={newTheme}>
          <CssBaseline />
          <Root />
        </ThemeProvider>
      </ColorModeContext.Provider>
    </StyledEngineProvider>
  );
};

export default App;

// ThemeSwitcher.js

import React from "react";
import { Divider, MenuItem } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { ColorModeContext } from "ColorModeContext";
import Popover from '@mui/material/Popover';

const ThemeSwitcher = () => {
  const theme = useTheme();
  const { toggleColorMode } = React.useContext(ColorModeContext);


  const [anchorEl, setAnchorEl] = React.useState(null);

  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  const open = Boolean(anchorEl);
  const id = open ? 'simple-popover' : undefined;

  const switchMode = (mode) => {
    toggleColorMode(mode);
    localStorage.setItem("theme", mode);
  };

  React.useEffect(() => {
    if (!localStorage.getItem("theme")) {
      localStorage.setItem("theme", "system");
    }
  }, []);

  return (
<>
      <Button aria-describedby={id} variant="contained" onClick={handleClick}>
        theme.palette.mode === "dark" ? (
          <Brightness7Icon sx={{ fill: "#fff" }} />
        ) : (
          <Brightness4Icon sx={{ fill: "#000" }} />
        )Ï
      </Button>

      <Popover
        id={id}
        open={open}
        anchorEl={anchorEl}
        onClose={handleClose}
        anchorOrigin={{
          vertical: 'bottom',
          horizontal: 'left',
        }}
      >
      <MenuItem onClick={() => switchMode("light")}>Light</MenuItem>

      <Divider sx={{ mb: "0.5rem", borderBottom: "thin dashed rgba(145, 158, 171, 0.24)" }} />

      <MenuItem onClick={() => switchMode("dark")}>Dark</MenuItem>

      <Divider sx={{ mt: "0.5rem", borderBottom: "thin dashed rgba(145, 158, 171, 0.24)" }} />

      <MenuItem onClick={() => switchMode("system")}>System</MenuItem>
    </Popover>
</>
  );
};

export default ThemeSwitcher;

Upvotes: 0

chujudzvin
chujudzvin

Reputation: 1303

I followed the answer of @giorgiline and everything was fine till the local storage part, which for me seems to be a bit too complicated.

What I did instead is in the ThemeContext.tsx looks like this:

This bit is different:

    const [themeMode, setThemeMode] = useState("light");

    useEffect(() => {
        // reading the storage
        const stored = localStorage.getItem("theme");
        setThemeMode(stored ? JSON.parse(stored) : "light");
    }, []);


    function updateTheme(theme: string){
        // setting the storage
        setThemeMode(theme) 
        localStorage.setItem("theme", JSON.stringify(theme));
    }

    const toggleTheme = () => {
        switch (themeMode) {
            case 'light':
                updateTheme("highContrast")
                break
            case 'highContrast':
                updateTheme("light")
                break
            default:
        }
    }

Here the whole file

import {createContext, ReactNode, useContext, useEffect, useState} from 'react'
import {createTheme, ThemeProvider as MuiThemeProvider} from '@mui/material'
import lightTheme from "../styles/theme/lightTheme";
import hightContrastTheme from "../styles/theme/hightContrastTheme";
import theme from "@storybook/addon-interactions/dist/ts3.9/theme";

interface ThemeContextType {
    themeMode: string
    toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType)
const useThemeContext = () => useContext(ThemeContext)



const ThemeProvider = ({ children }: { children: ReactNode }) => {
    const [themeMode, setThemeMode] = useState("light");

    useEffect(() => {
        const stored = localStorage.getItem("theme");
        setThemeMode(stored ? JSON.parse(stored) : "light");
    }, []);


    function updateTheme(theme: string){
        setThemeMode(theme)
        localStorage.setItem("theme", JSON.stringify(theme));
    }

    const toggleTheme = () => {
        switch (themeMode) {
            case 'light':
                updateTheme("highContrast")
                break
            case 'highContrast':
                updateTheme("light")
                break
            default:
        }
    }

    return (
        <ThemeContext.Provider value={{ themeMode, toggleTheme }}>
            <MuiThemeProvider theme={themeMode === 'light' ? createTheme(lightTheme) : createTheme(hightContrastTheme)}>
                {children}
            </MuiThemeProvider>
        </ThemeContext.Provider>
    )
}

export {
    useThemeContext,
    ThemeProvider
}

Upvotes: 0

giorgiline
giorgiline

Reputation: 1371

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.

Upvotes: 7

Sarang Sami
Sarang Sami

Reputation: 702

  toggleColorMode: () => {
        //use this line for save in localStorage
        localStorage.setItem("mode",mode=== 'light' ? 'dark' : 'light' )
        setMode((prevMode) =>
          prevMode === 'light' ? 'dark' : 'light',
        );
      },

then write a useEffect to set mode based on localStorage

useEffect(()=>{
   if( localStorage.getItem("mode")){
    setMode(localStorage.getItem("mode"))
     }
},[])

Upvotes: 2

Related Questions