從深度嵌套的組件更新狀態而不重新渲染父組件 (Update state from deeply nested component without re-rendering parents)


問題描述

從深度嵌套的組件更新狀態而不重新渲染父組件 (Update state from deeply nested component without re‑rendering parents)

input and map inside content, content inside page, page and button inside layout

我有一個表單頁面結構大致如下:

<Layout>
  <Page>
    <Content>
      <Input />
      <Map />
    </Content>
  </Page>
  <Button />
</Layout>

Map 組件應該只渲染一次,因為渲染時會觸發動畫。這意味著內容、頁面和佈局根本不應該重新渲染。

當輸入為空時,佈局內的按鈕應該被禁用。Input 的值不受 Content 控制,因為狀態更改會導致重新渲染 Map。

我嘗試了一些不同的方法(使用 refs、useImperativeHandle、等),但沒有一個解決方案對我來說很乾淨。在不更改佈局、頁面或內容狀態的情況下,將輸入狀態連接到按鈕狀態的最佳方法是什麼?請記住,這是一個相當小的項目,代碼庫使用“現代” React 實踐(例如鉤子),並且沒有像 Redux、MobX 等那樣的全局狀態管理。


參考解法

方法 1:

Here is an example (click here to play with it) that avoids re‑render of Map. However, it re‑renders other components because I pass children around. But if map is the heaviest, that should do the trick. To avoid rendering of other components you need to get rid of children prop but that most probably means you will need redux. You can also try to use context but I never worked with it so idk how it would affect rendering in general

import React, { useState, useRef, memo } from "react";
import "./styles.css";

const GenericComponent = memo(
  ({ name = "GenericComponent", className, children }) => {
    const counter = useRef(0);
    counter.current += 1;

    return (
      <div className={"GenericComponent " + className}>
        <div className="Counter">
          {name} rendered {counter.current} times
        </div>
        {children}
      </div>
    );
  }
);

const Layout = memo(({ children }) => {
  return (
    <GenericComponent name="Layout" className="Layout">
      {children}
    </GenericComponent>
  );
});

const Page = memo(({ children }) => {
  return (
    <GenericComponent name="Page" className="Page">
      {children}
    </GenericComponent>
  );
});

const Content = memo(({ children }) => {
  return (
    <GenericComponent name="Content" className="Content">
      {children}
    </GenericComponent>
  );
});

const Map = memo(({ children }) => {
  return (
    <GenericComponent name="Map" className="Map">
      {children}
    </GenericComponent>
  );
});

const Input = ({ value, setValue }) => {
  const onChange = ({ target: { value } }) => {
    setValue(value);
  };
  return (
    <input
      type="text"
      value={typeof value === "string" ? value : ""}
      onChange={onChange}
    />
  );
};

const Button = ({ disabled = false }) => {
  return (
    <button type="button" disabled={disabled}>
      Button
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672</h1>

      <Layout>
        <Page>
          <Content>
            <Input value={value} setValue={setValue} />
            <Map />
          </Content>
        </Page>
        <Button disabled={value === ""} />
      </Layout>
    </div>
  );
}

Update

Below is version with context that does not re‑render components except input and button:

import React, { useState, useRef, memo, useContext } from "react";
import "./styles.css";

const ValueContext = React.createContext({
  value: "",
  setValue: () => {}
});

const Layout = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Layout rendered {counter.current} times</div>
      <Page />
      <Button />
    </div>
  );
});

const Page = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Page rendered {counter.current} times</div>
      <Content />
    </div>
  );
});

const Content = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Content rendered {counter.current} times</div>
      <Input />
      <Map />
    </div>
  );
});

const Map = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Map rendered {counter.current} times</div>
    </div>
  );
});

const Input = () => {
  const { value, setValue } = useContext(ValueContext);

  const onChange = ({ target: { value } }) => {
    setValue(value);
  };

  return (
    <input
      type="text"
      value={typeof value === "string" ? value : ""}
      onChange={onChange}
    />
  );
};

const Button = () => {
  const { value } = useContext(ValueContext);

  return (
    <button type="button" disabled={value === ""}>
      Button
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672, method 2</h1>

      <p>
        Type something into input below to see how rendering counters{" "}
        <s>update</s> stay the same
      </p>

      <ValueContext.Provider value={{ value, setValue }}>
        <Layout />
      </ValueContext.Provider>
    </div>
  );
}

Solutions rely on using memo to avoid rendering when parent re‑renders and minimizing amount of properties passed to components. Ref's are used only for render counters

方法 2:

I have a sure way to solve it, but a little more complicated. Use createContext and useContext to transfer data from layout to input. This way you can use a global state without using Redux. (redux also uses context by the way to distribute its data). Using context you can prevent property change in all the component between Layout and Imput.

I have a second easier option, but I'm not sure it works in this case. You can wrap Map to React.memo to prevent render if its property is not changed. It's quick to try and it may work.

UPDATE

I tried out React.memo on Map component. I modified Gennady's example. And it works just fine without context. You just pass the value and setValue to all component down the chain. You can pass all property easy like: <Content {...props} /> This is the easiest solution.

import React, { useState, useRef, memo } from "react";
import "./styles.css";

const Layout = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Layout rendered {counter.current} times</div>
      <Page {...props} />
      <Button {...props} />
    </div>
  );
};

const Page = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Page rendered {counter.current} times</div>
      <Content {...props} />
    </div>
  );
};

const Content = props => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Content rendered {counter.current} times</div>
      <Input {...props} />
      <Map />
    </div>
  );
};

const Map = memo(() => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <div className="GenericComponent">
      <div className="Counter">Map rendered {counter.current} times</div>
    </div>
  );
});

const Input = ({ value, setValue }) => {
  const counter = useRef(0);
  counter.current += 1;

  const onChange = ({ target: { value } }) => {
    setValue(value);
  };

  return (
    <>
      Input rendedred {counter.current} times{" "}
      <input
        type="text"
        value={typeof value === "string" ? value : ""}
        onChange={onChange}
      />
    </>
  );
};

const Button = ({ value }) => {
  const counter = useRef(0);
  counter.current += 1;

  return (
    <button type="button" disabled={value === ""}>
      Button (rendered {counter.current} times)
    </button>
  );
};

export default function App() {
  const [value, setValue] = useState("");

  return (
    <div className="App">
      <h1>SO Q#60060672, method 2</h1>

      <p>
        Type something into input below to see how rendering counters{" "}
        <s>update</s> stay the same, except for input and button
      </p>
      <Layout value={value} setValue={setValue} />
    </div>
  );
}

https://codesandbox.io/s/weathered‑wind‑wif8b

(by Joe MaffeiGennady DogaevPeter Ambruzs)

參考文件

  1. Update state from deeply nested component without re‑rendering parents (CC BY‑SA 2.5/3.0/4.0)

#react-context #react-hooks #reactjs #Next.js #react-state-management






相關問題

在項目中使用 Context API 我以前會在異步操作創建者中使用 Redux? (Using Context API in a project I would have previously used Redux in - async action creators?)

Nextjs 和上下文 API (Nextjs and Context API)

從 redux-saga 函數訪問 React 上下文值 (Access React context value from redux-saga function)

如何在 Django 應用程序中用 react ContextAPI 替換 Redux (How can you replace Redux by the react ContextAPI in a Django App)

從深度嵌套的組件更新狀態而不重新渲染父組件 (Update state from deeply nested component without re-rendering parents)

更新 useContext 中的值時,組件不會重新呈現 (Component not re rendering when value from useContext is updated)

為什麼我無法從反應上下文訪問變量?你能找出錯誤嗎? (Why am I not able to access variable from react context? Can you identify the mistake?)

React Hook useEffect 缺少依賴項(在上下文中定義的函數) (React Hook useEffect has a missing dependency (function defined in context))

React Context 是道具鑽探的解毒劑嗎? (Is React Context an antidote for prop drilling?)

主題提供者問題 (Theme Provider issue)

打字稿反應上下文+類型'{}'沒有調用簽名 (Typescript react context + Type '{}' has no call signatures)

next.js socket.io 使用效果不更新套接字消息 (next.js socket.io useeffect not updating on socket message)







留言討論