React + Material UI - 在切换 parent 主题时防止 child 树重新挂载的最佳方法

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

背景

我想遵循 Material UI 切换 UI 的 dark/light 模式主题的实现。 Link.

我已将其实现封装到一个自定义挂钩中,调用时该挂钩具有 returns theme-related 属性。这是我的 App() 水平。


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
    }
}

App() 级别,我有 returns 以下应用程序组件:

...
    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>
)

最后,在我的 header 组件中(作为上下文消费者,在 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>
)

问题

切换主题有效,但是,如果我在我的组件 <Create /><Query /> 中切换主题,整个组件树(创建或查询)将重新挂载,并且所有状态在组件内刷新为初始状态。

基本上,如果我在我的 <Create /> 组件内,并且我正在填写创建表单,该表单的值由 useState 维护,只要我切换主题,所有的值都重置为它们的初始值(传递到 useState 挂钩)

问题

有没有办法防止这种重新挂载的发生?我知道这不仅仅是重新渲染组件,因为状态已重新初始化。如果这不是重新渲染问题,那么 React.memo 可以在这里工作吗?如果没有,什么是在 child 组件内切换主题(parent 组件级别的上下文)而无需重新安装组件的最佳方法。


添加一个codesandboxlink。它应该包括我遇到的问题的一个简单示例。当我切换 parent 的主题模式时,child 组件重新安装(由 Child 的 useEffect 记录)。

useTheme 挂钩应该 return 提供的上下文值,而不是上下文本身。这是重新创建上下文提供程序并重新安装子项。

示例:

使用主题

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;

应用程序

导入新的 ColorModeContextProvider provider 组件以提供主题和颜色模式上下文,useTheme 挂钩将在 Header 组件中使用以访问 toggleColorMode回调。

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;

useAuth 挂钩也需要对其应用类似的重构。