'React + Material UI - Best way to prevent child tree from remount when toggling parent theme

Background

I wanted to follow Material UI's implementation of toggling UI's dark/light mode theme. Link.

I have encapsulated its implementation into a custom hook that returns theme-related properties when called. This is called at my App() level.


import { createContext, useState, useMemo } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';


export default function useTheme() {

    const ColorModeContext = createContext({ toggleColorMode: () => {}, mode: null})
    const [ mode, setMode ] = useState('light')

    const colorMode = useMemo(
        () => ({
            toggleColorMode : () => {
              setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'))  
            },
        })
    , [])  


    // recreate theme everytime mode changes
    const appTheme = useMemo( () => (createTheme({
        palette: {
          mode: mode,
          primary: ...
        },
      })), [mode])
    
    
    
    return {
        ColorModeContext,
        colorMode,
        ThemeProvider,
        appTheme
    }
}

At the App() level, I have it returns the following App Component:

...
    const {
        ColorModeContext,
        colorMode,
        ThemeProvider,
        appTheme
    } = useTheme()

    // another custom hook to return auth related properties
    const {
        AuthContext,
        authed,
        ...
    } = useAuth()
   return (
     <ColorModeContext.Provider value={colorMode}>
            <ThemeProvider theme={appTheme}>
                <CssBaseline />
                <AuthContext.Provider value=...>
                    <Box>
                        <Router>
                            <Header 
                                ColorModeContext={ColorModeContext}
                                theme={appTheme}
                                authed={authed}
                                ...
                            /> 
                            <Routes>
                                <Route 
                                    path="/" 
                                    element={
                                        <Signin AuthContext={AuthContext} />
                                    } 
                                />
                                <Route 
                                    path="/create" 
                                    element={
                                        <RequiredAuth authed={authed}>
                                            <Create />
                                        </RequiredAuth>
                                    } 
                                />
                                <Route 
                                    path="/query" 
                                    element={
                                        <RequiredAuth authed={authed}>
                                            <Query />
                                        </RequiredAuth>
                                    } 
                                />
                            </Routes>
                            ...
                        </Router>
                    </Box>
                </AuthContext.Provider>
            </ThemeProvider>
        </ColorModeContext.Provider>
)

Lastly, in my header component (as the context consumer, to have the toggle callback function exposed in the header)

...
const { ColorModeContext, authed, ... } = props;

return (
        <ColorModeContext.Consumer>
            {
                ({toggleColorMode}) => (
                    <Box>
                        <AppBar position="static" enableColorOnDark>
                            <Toolbar>
                              ...
                                <IconButton
                                  size="small"
                                  onClick={toggleColorMode}
                                  color="inherit"
                                >
                                   {
                                       theme.palette.mode === 'dark' ? 
                                          <LightModeIcon /> : <Brightness3Icon />
                                   }
                               </IconButton>
                            ...                   

                            </Toolbar>
                        </AppBar>
                    </Box>
                )
            }
        </ColorModeContext.Consumer>
)

ISSUE

Toggling the theme works, however, If I am inside my component <Create /> or <Query /> and I toggle the theme, the entire component tree (Create or Query) is remounted, and all states within the component refreshed to its initial state.

Basically, if I am inside my <Create /> component, and I am filling out the create form, which the values of the form are maintained by useState, as soon as I toggle the theme, all of the values resets to their initial value (which was passed into the useState hook)

QUESTION

Is there a way to prevent this remount from happening? I know it's not just rerendering the component, because the state is reinitialized. If it's not a rerender issue, then would React.memo work here? If not, what would be the best way to toggle theme (context at a parent component lvl) while inside a child component without having the component remount.


Adding a codesandbox link. It should include a simple example of the issue that I am having. The child component remounts (as logged by the Child's useEffect) when I toggle parent's theme mode.



Solution 1:[1]

The useTheme hook should return the provided context value, not the context itself. This is recreating a context provider and remounting children.

Example:

useTheme

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

const ColorModeContext = createContext({
  toggleColorMode: () => {},
  mode: null
});

const ColorModeContextProvider = ({ children }) => {
  const [mode, setMode] = useState("light");

  const toggleColorMode = () => {
    setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
  };

  // recreate theme everytime mode changes
  const appTheme = useMemo(
    () =>
      createTheme({
        palette: {
          mode
        }
      }),
    [mode]
  );

  return (
    <ColorModeContext.Provider
      value={{
        toggleColorMode,
        mode
      }}
    >
      <ThemeProvider theme={appTheme}>{children}</ThemeProvider>
    </ColorModeContext.Provider>
  );
};

export const useTheme = () => useContext(ColorModeContext);

export default ColorModeContextProvider;

App

Import the new ColorModeContextProvider provider component to provide the theme and color mode contexts, and the useTheme hook to be used in the Header component to access the toggleColorMode callback.

import { useState, useEffect } from "react";
import { Container, CssBaseline } from "@mui/material";
import ColorModeContextProvider, { useTheme } from "./useTheme";
import useAuth from "./useAuth";

function App() {
  const { AuthContext, authed, user, login, logout } = useAuth();

  return (
    <ColorModeContextProvider>
      <CssBaseline />
      <AuthContext.Provider value={{ authed, user, login, logout }}>
        <Container maxWidth="lg">
          <Header />
        </Container>
        <Child />
      </AuthContext.Provider>
    </ColorModeContextProvider>
  );
}

function Header() {
  const { toggleColorMode } = useTheme();
  return <button onClick={toggleColorMode}>ToggleTheme</button>;
}

function Child() {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    return () => {
      console.log("Child component unmounting");
    };
  }, []);

  return (
    <>
      <button
        onClick={() => {
          setCounter((count) => count + 1);
        }}
      >
        Add
      </button>
      <div>{counter}</div>
    </>
  );
}

export default App;

Edit react-material-ui-best-way-to-prevent-child-tree-from-remount-when-toggling

The useAuth hook needs a similar refactoring applied to it as well.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Drew Reese