Search code examples
javascriptcssmaterial-uimaterial-designthemes

Is there a way to persist theme changes between pages? [Material UI]


When using MUI 5.14.1, I am receiving an error whenever I try to persist themes through pages using localStorage. I'd appreciate any help on how I might fix this, or advice on how I could go about this differently.

import React, { useState } from "react";
import {
  CssBaseline,
  Button,
  Typography,
} from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import { Toaster, toast } from "react-hot-toast";
import { Link } from "react-router-dom"; // Import the Link component from react-router-dom
import styles from "../styles/app.module.css";
import MuteSwitch from "../components/MuteSwitch.js";
import StyledAvatar from "../components/StyledAvatar.js";
import Sidebar from "../components/Sidebar";
import {
  toggleDarkMode,
  handleThemeChange,
  lightTheme,
} from "../utils/themeUtils";

const persistedTheme = JSON.parse(localStorage.getItem("theme")) || lightTheme;
const HomePage = () => {
  const [currentTheme, setCurrentTheme] = useState(persistedTheme); // Define the state variable for the current theme
  const [darkMode, setDarkMode] = useState(false); // Track dark mode state, false = light mode, true = dark mode
  const [userInputColor, setUserInputColor] = useState("#1976d2"); // Default initial color
  const [colorPickerColor, setColorPickerColor] = useState("#1976d2"); // Default initial color

  const saveTheme = (theme) => {
    localStorage.setItem("theme", JSON.stringify(currentTheme));
  };

  const handleColorChange = (event) => {
    setColorPickerColor(event.target.value);
    setUserInputColor(event.target.value);
  };

  const handleDarkModeToggle = () => {
    setDarkMode((prevMode) => !prevMode); // Toggle the dark mode state
    toggleDarkMode(darkMode, setCurrentTheme);
  };

  const createToast = (message) => {
    let toastBackground = currentTheme.palette.primary.main;
    let toastColor = currentTheme.palette.primary.contrastText;
    toast.success(message, {
      style: {
        background: toastBackground,
        color: toastColor,
      },
    });
  };
  const handleNewMessages = () => {
    createToast("You have 3 new messages");
  };

  const onThemeChange = () => {
    //possibly darken color picker color
    const updatedTheme = handleThemeChange(userInputColor);
    setCurrentTheme(updatedTheme);
    saveTheme(updatedTheme);
  };

  return (
    <>
      <Toaster />

      <ThemeProvider theme={currentTheme}>
        <CssBaseline />

        <div className={styles.heading}>
          <Typography variant="h1" component="h1" gutterBottom>
            Home Page
          </Typography>
        </div>

        {/* content */}
        <div className={styles.centeredContent}>
          <Button variant="contained">Pretty Colors</Button>
        </div>
        {/* mute switch */}
        <div className={styles.muteSwitch}>
          <MuteSwitch />
        </div>
        {/* avatar */}
        <Link to="/profile" style={{ textDecoration: "none", color: "inherit" }}>
        <div className={styles.avatar}>
          <StyledAvatar>TS</StyledAvatar>
        </div>
        </Link>

        {/* drawer */}
        <div>
          <Sidebar handleThemeChange={onThemeChange} darkMode={darkMode} handleDarkModeToggle={handleDarkModeToggle} handleNewMessages={handleNewMessages} colorPickerColor={colorPickerColor} handleColorChange={handleColorChange}/>
        </div>
      </ThemeProvider>
    </>
  );
};

export default HomePage;

Error:

Unexpected Application Error!
theme.transitions.create is not a function
TypeError: theme.transitions.create is not a function
    at http://localhost:3000/static/js/bundle.js:11135:35

I tried using localStorage to store it, expecting it to let me retrieve the theme in the new file, but whenever I edit const [currentTheme, setCurrentTheme] = useState(lightTheme); to use the persisted theme it gives me an error.

EDIT: This has now been solved. Consider using my solution, which uses React Context, or Dewaun Ayers' solution (marked as solution in the replies), which uses localStorage.

My solution:

Create a file called "ThemeContext.js", or something similar, which contains the following:

// ThemeContext.js

import { createContext, useContext, useState } from "react";
import { createTheme } from "@mui/material/styles";

const ThemeContext = createContext();

export const useThemeContext = () => {
  return useContext(ThemeContext);
};

export const ThemeContextProvider = ({ children }) => {
  const [currentTheme, setCurrentTheme] = useState(lightTheme);

  const handleThemeChange = (color) => {
    const secondaryColor = color; //edit this to be your secondary color
    const newTheme = createTheme({
      palette: {
        primary: {
          main: color,
        },
        secondary: {
          main: secondaryColor,
        },
      },
    });

    setCurrentTheme(newTheme);
  };


  return (
    <ThemeContext.Provider
      value={{ currentTheme, handleThemeChange }}
    >
      {children}
    </ThemeContext.Provider>
  );
};

Import this file into your top level React file, and wrap your router/components in the ThemeContextProvider component. My implementation as as follows:

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import HomePage from "./pages/HomePage";
import ProfilePage from "./pages/ProfilePage";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { ThemeContextProvider } from "./utils/ThemeContext";

const router = createBrowserRouter([
  {
    path: "/",
    element: <HomePage />,
  },
  {
    path: "/profile",
    element: <ProfilePage />,
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <ThemeContextProvider>
      <RouterProvider router={router} />
    </ThemeContextProvider>
  </React.StrictMode>
);

Finally, edit your page components to use the variables and functions stored in Context, like below:

import { useThemeContext } from "../utils/ThemeContext";

const HomePage = () => {
  const { currentTheme, handleThemeChange, isDarkMode, toggleDarkMode, colorPickerColor, userInputColor, handleColorChange } = useThemeContext();

  const createToast = (message) => {
    let toastBackground = currentTheme.palette.primary.main;
    let toastColor = currentTheme.palette.primary.contrastText;
    toast.success(message, {
      style: {
        background: toastBackground,
        color: toastColor,
      },
    });
  };
  const handleNewMessages = () => {
    createToast("You have 3 new messages");
  };

  const onThemeChange = () => {
    //possibly darken color picker color
    handleThemeChange(userInputColor);
  };

  return (
    <>
      <Toaster />

      <ThemeProvider theme={currentTheme}>
        <CssBaseline />

        <div className={styles.heading}>
          <Typography variant="h1" component="h1" gutterBottom>
            Home Page
          </Typography>
        </div>

        {/* content */}
        <div className={styles.centeredContent}>
          <Button variant="contained">Pretty Colors</Button>
        </div>
        {/* mute switch */}
        <div className={styles.muteSwitch}>
          <MuteSwitch />
        </div>
        {/* avatar */}
        <Link
          to="/profile"
          style={{ textDecoration: "none", color: "inherit" }}
        >
          <div className={styles.avatar}>
            <StyledAvatar>TS</StyledAvatar>
          </div>
        </Link>

        {/* drawer */}
        <div>
        <Sidebar
            handleThemeChange={onThemeChange}
            isDarkMode={isDarkMode}
            handleDarkModeToggle={toggleDarkMode}
            handleNewMessages={handleNewMessages}
            colorPickerColor={colorPickerColor}
            handleColorChange={handleColorChange}
            currentTheme={currentTheme}
          />
        </div>
      </ThemeProvider>
    </>
  );
};

export default HomePage;

This commit to the project repository shows the full changes: https://github.com/AnthonySchneider2000/React-Material-UI-Dynamic-Theme-Changer/commit/5e89229b8ec04e2cc3aed3b7fc7205b1396ee401


Solution

  • You are on the right track for persisting the theme to local storage.

    I think you are seeing that error because either:

    You aren't creating a theme the MUI way by using the createTheme function, passing it a valid theme object, then passing that to the ThemeProvider

    OR

    You are creating a valid theme in your imported theme files and the functions (like transitions.create) are destroyed when you stringify the theme object before saving to local storage.

    If it's the second reason, the description for JSON.stringify() states:

    undefined, Function, and Symbol values are not valid JSON values. If any such values are encountered during conversion, they are either omitted (when found in an object) or changed to null (when found in an array). JSON.stringify() can return undefined when passing in "pure" values like JSON.stringify(() => {}) or JSON.stringify(undefined).

    Ideally, you will want to only store the theme values in local storage (before passing it into createTheme), or a token that represents the selected theme and use that to pick which theme to show the user.

    I created this Code Sandbox to demonstrate a different way of switching themes while also persisting to local storage.

    Hope this answers your question!