Search code examples
reactjsreact-hooksnext.jsreact-contextreact-state-management

Update state from deeply nested component without re-rendering parents


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

I have a form page structured more or less as follows:

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

The Map component should only be rendered once, as there is an animation that is triggered on render. That means that Content, Page and Layout should not re-render at all.

The Button inside Layout should be disabled when the Input is empty. The value of the Input is not controlled by Content, as a state change would cause a re-render of the Map.

I've tried a few different things (using refs, useImperativeHandle, etc) but none of the solutions feel very clean to me. What's the best way to go about connecting the state of the Input to the state of the Button, without changing the state of Layout, Page or Content? Keep in mind that this is a fairly small project and the codebase uses "modern" React practices (e.g. hooks), and doesn't have global state management like Redux, MobX, etc.


Solution

  • 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