Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 421
Joe Maffei Avatar asked Feb 04 '20 15:02

Joe Maffei


People also ask

How do I update my nested state?

To update nested properties in a state object in React: Pass a function to setState to get access to the current state object. Use the spread syntax (...) to create a shallow copy of the object and the nested properties. Override the properties you need to update.

How do you avoid full component re-renders?

1. Memoization using useMemo() and UseCallback() Hooks. Memoization enables your code to re-render components only if there's a change in the props. With this technique, developers can avoid unnecessary renderings and reduce the computational load in applications.

What technique can be used to prevent child component from re-rendering?

memo() If you're using a React class component you can use the shouldComponentUpdate method or a React. PureComponent class extension to prevent a component from re-rendering.

Do we need to update a property in nested state?

Pretty uncontroversial up to this point! But what if we have nested state? That is, we need to update a property in our state object one (or more) levels deep? Let’s learn some conventions for how to do this in different scenarios. In a new example, we have a stateful user that has an email property and password property (think signup form).

Why is it so hard to update a nested state in react?

The pain of updating a nested state stems from the fundamental architectural decision of the React to embrace immutability. Immutability comes with plenty of great benefits, such as predictability and performance, so the trade-off is worth it. There are two main ways to deal with the problem of updating a deeply nested state.

How to prevent react child components from re-rendering?

Wrap your children in React.memo (), this way every time one of your children updates a state in the Parent component, all the children that don't care and don't receive that updated state as a prop, won't re-render. Use a global state manager ( Suggested ).

How to pass events from parent component to child component?

Normally we pass an event from the parent to the child component, the child component receives the event, and when the event (method) is called with parameters, the method defined in the parent component is triggered and the state is then updated.


2 Answers

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

like image 161
Gennady Dogaev Avatar answered Sep 18 '22 01:09

Gennady Dogaev


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

like image 20
Peter Ambruzs Avatar answered Sep 18 '22 01:09

Peter Ambruzs