简体   繁体   中英

How to only re-render individual child components with useRef and useEffect

tl;dr - how to force re-render only one specific child component by tracking ref ?

I have a table of rows. I'd like to be able to hover on rows and show/hide a cell in the row, but only after a while.

You can only reveal hidden hover content after hovering over the entire table for some period of time - triggered by onMouseEnter and onMouseLeave .

Once hovering a particular <Row> , it should show the extra content if it's allowed to by the parent.

The sequence for mouse over the table:

  1. Hover over row
  2. Row's isHovered is now true
  3. In 1000ms, allowHover changes to true
  4. Since allowHover and isHovered are both true , show extra row content

The sequence for mouse OUT the table:

  1. Mouse moves outside of the parent container/table/row
  2. Previously hovered row's isHovered is set to false
  3. Previously hovered row's hidden content is hidden
  4. In 1000ms, allowHover changes to false

At this point, if re-entering the table, we'd have to wait for 1 second again before allowHover is true. Once both isHovered and allowHover are true, display hidden content. Once hover is allowed, there are no delays involved: rows hovered over should immediately reveal the hidden content.

I'm trying to employ useRef to avoid mutating state of the rows' parent and causing a re-render of all the child rows

At the row level, on hover, a row should be able to check if hover is allowed without the entire list being re-rendered with props. I assumed useEffect could be set to track the value but it doesn't seem to trigger a re-render at the individual component level.

In other words, expected behavior is for the currently hovered over row to detect the change in the parent and only re-render itself to reveal content . Then, once hovering is allowed the behavior is straightforward. Hover over row? Reveal its content.

Here's the snippets of code involved:

function Table() {
  const allowHover = useRef(false);

  const onMouseEnter = (e) => {
    setTimeout(() => {
      allowHover.current = true; // allow hovering
    }, 1000);
  };
  const onMouseLeave = (e) => {
    setTimeout(() => {
      allowHover.current = false; // dont allow hovering
    }, 1000);
  };

  return (
    <div className="App" style={{ border: '3px solid blue' }}>
      <h1>table</h1>
      {/* allow/disallow hovering when entering and exiting the table, with a delay */}
      <table onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
        <tbody>
          <AllRows allowHover={allowHover} />
        </tbody>
      </table>
    </div>
  );
}

function Rows(props) {
  return [1, 2, 3].map((id) => (
    <Row id={id} allowHover={props.allowHover} />
  ));
}

function Row(props) {
  let [isHovered, setIsHovered] = useState(false);

  useEffect(() => {
    // Why isn't this re-rendering this component?
  }, [props.allowHover]);

  const onMouseEnter = ({ target }) => {
    setIsHovered(true);
  };
  const onMouseLeave = ({ target }) => {
    setIsHovered(false);
  };

  console.log('RENDERING ROW');
  return (
    <tr key={props.id} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
      <td style={{ border: '1px solid red' }}>---------------- {props.id}</td>
      <td style={{ border: '1px solid green' }}>
        {props.allowHover.current && isHovered ? (
          <button>ACTIONS</button>
        ) : null}
      </td>
    </tr>
  );
}

Basics

React

One, if not THE , biggest of React's advantages is that it lets us manage an application with state without interacting with the DOM via manual updates and event listeners. Whenever React "rerenders", it renders a virtual DOM which it then compares to the actual DOM, doing replacements where necessary.

Hooks

  • useState : returns a state and a setter, whenever the setter is executed with a new state, the component rerenders.
  • useRef : provides a possibility to keep a reference to a value or object between renders. It's like a wrapper around a variable which is stored at the ref's current property and which is referentially identical between renders as long as you don't change it. Setting a new value to a ref does NOT cause the component to rerender. You can attach actual DOM nodes to refs but you can really attach anything to a ref.
  • useEffect : code to run after a component has rendered. The useEffect is executed after the DOM has been updated.
  • memo : adds possibility to manually control when a child rerenders after the parent has rerendered.

Your code

I'm going to assume that this is some kind of toy example and that you want to understand how React works, if not, doing this by direct manipulation of DOM nodes is not the way to go. As a matter of fact, you should ONLY use memo and performances improvements and direct manipulation of DOM nodes when it's not possible to accomplish what you want in another way, or if doing it the the regular way with React does not yield acceptable performance. In your case, the performance would be good enough.

There are libraries that do most of the work outside of React. One of those is react-spring which animates DOM elements. Doing these animations in React would slow them down and make them lag, therefore react-spring uses refs and updates DOM nodes directly, setting different CSS properties right on the element.

Your questions

  • You wonder why the useEffect in Row is not triggered whenever you change the content of the ref. Well, this is simply because useEffect runs after render, and there is no guarantee that the component will rerender just because you change the content of the allowHover ref. The ref passed to AllRows and Row is the same property all the time (only its current property changes), therefore they will never rerender due to props being changed. Since Row only rerenders, by itself, when isHovered is set, there is no guarantee that the useEffect will fire just because you change the content of allowHover ref. WHEN Row rerenders, the effect will run IF the value of allowHover.current is different from last time.
  • Using memo will not help you here either since Table or AllRows don't rerender either. memo allows us to skip rerendering children when parents rerender, but here, the parents don't rerender, so memo will do nothing.

All in all, neither useEffect or memo are some kind of magic functions that keep track of variables at all times and then do something when these change, instead, they are just functions that are executed at given times in the React lifecycle, evaluating the current context.

Your use case

Basically, whether a Row should be visible or not depends on two conditions:

  • Is allowHover.current set to true ?
  • Is isHovered set to true ?

Since these don't depend on each other, we should ideally like to be able to modify the conditional content from event listeners attached to both of the events which change the values of these properties.

In a vanilla Javascript environment, we would perhaps store each element depending on this in an array and set its display or visibility from the event listeners which would check both of these conditions; whichever event listener that fires last would be responsible for showing or hiding the component / row.

Doing the same in React, but bypassing React, should be quite straightforward as long as you can store this state in some ref. Since both events occurring on Table level and Row level have to be able to modify the elements in question, access to these DOM elements must be available in both of these components. You can accomplish this by either merging the code of Table , Row and AllRows into one component or pass refs from the children back up to the parent component in some elaborate scheme. The point here is, if you want to do it outside of React, ALL of this should be done outside of React.

Your current problem in the code is that you want to update one of the conditions ( allowHover ) outside of React but you want React to take care of the other condition ( isHovered ). This creates an odd situation which is not advisable no matter if you would really want to do this outside of React (which I advise against in all cases except toy scenarios) or not. React does not know when allowHover is set to true since this is done outside of React.

Solutions:

1. Use React

Simply use useState for the allowHover so that Table rerenders whenever allowHover changes. This will update the prop in the children which will rerender too. Also make sure to store the timeout in a ref so that you may clear it whenever you move the mouse in and out of the table.

With this solution, the Table and all its children will rerender whenever the mouse passes in and out of the table (after 1 s.) and then individual Row s will rerender whenever isHovered for that Row is changed. The result is that Row s will rerender on both the conditions which control whether they should contain the conditional content or not.

function Table() {

  const [allowHover, setAllowHover] = useState(false);
  const hoverTimeout = useRef(null)

  const onMouseEnter = (e) => {
    if (hoverTimeout.current !== null) clearTimeout(hoverTimeout.current);
    hoverTimeout.current = setTimeout(() => {
      console.log("Enabling hover")
      setAllowHover(true); // allow hovering
    }, 1000);
  }

  const onMouseLeave = (e) => {
    if (hoverTimeout.current !== null) clearTimeout(hoverTimeout.current);
    hoverTimeout.current = setTimeout(() => {
      console.log("Disabling hover")
      setAllowHover(false); // dont allow hovering
    }, 1000);
  }

  console.log("Rendering table")

  return (
    <div className="App" style={{ border: "3px solid blue" }}>
      <h1>table</h1>
      {/* allow/disallow hovering when entering and exiting the table, with a delay */}
      <table
        style={{ border: "3px solid red" }}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
      >
        <tbody>
          <Rows allowHover={allowHover} />
        </tbody>
      </table>
    </div>
  );
}

function Rows(props) {
  console.log("Rendering rows")

  return [1, 2, 3].map((id) => (
    <Row id={id} key={id} allowHover={props.allowHover} />
  ));
}

function Row(props) {
  let [isHovered, setIsHovered] = useState(false);

  const onMouseEnter = ({ target }) =>
    setIsHovered(true)

  const onMouseLeave = ({ target }) => 
    setIsHovered(false)

  console.log("Rendering row")

  return (
    <tr key={props.id} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
      <td style={{ border: "1px solid red" }}>---------------- {props.id}</td>
      <td style={{ border: "1px solid green" }}>
        {props.allowHover && isHovered ? <button>ACTIONS</button> : null}
      </td>
    </tr>
  )
}

No funny business going on here, just plain React style.

Code sandbox: https://codesandbox.io/s/bypass-react-1a-i3dq8

2. Bypass React

Even though not recommended, if you do this, you should do all the updates outside of React. This means you can't depend on React to rerender child rows when you update the state of the Table outside of React. You could do this in many ways, but one way is for child Row s to pass their refs back up to the Table component which manually updates the Row s via refs. This is pretty much what React does under the hood actually.

Here, we add a lot of logic to the Table component which becomes more complicated but instead, the Row components lose some code:

function Table() {

  const allowHover = useRef(false)
  const timeout = useRef(null)
  const rows = useRef({})

  const onAddRow = (row, index) => {
    rows.current = {
      ...rows.current,
      [index]: rows.current[index]
        ? { ...rows.current[index], row }
        : { row, hovered: undefined }
    };
    onUpdate()
  };

  const onHoverRow = (hovered, index) => {
    rows.current = {
      ...rows.current,
      [index]: rows.current[index]
        ? { ...rows.current[index], hovered }
        : { hovered, row: undefined }
    };
    onUpdate()
  };

  const onUpdate = () => {
    Object.values(rows.current).forEach(({ row, hovered }) => {
      if (hovered && allowHover.current) {
        if (row) row.innerHTML = "<button>Accept</button>"
      } else {
        if (row) row.innerHTML = ""
      }
    });
  };

  const onMouseEnter = (e) => {
    if (timeout.current !== null) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => {
      console.log("Enabling hover on table")
      allowHover.current = true // allow hovering
      onUpdate()
    }, 1000)
  };
  const onMouseLeave = (e) => {
    if (timeout.current !== null) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => {
      console.log("Disabling hover on table")
      allowHover.current = false // dont allow hovering
      onUpdate()
    }, 1000)
  };

  console.log("Rendering table")

  return (
    <div className="App" style={{ border: "3px solid blue" }}>
      <h1>table</h1>
      {/* allow/disallow hovering when entering and exiting the table, with a delay */}
      <table onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
        <tbody>
          <Rows onAddRow={onAddRow} onHoverRow={onHoverRow} />
        </tbody>
      </table>
    </div>
  );
}

function Rows(props) {

  console.log("Rendering rows")

  return [1, 2, 3].map((id, index) => (
    <Row
      key={id}
      index={index}
      id={id}
      onAddRow={props.onAddRow}
      onHoverRow={props.onHoverRow}
    />
  ))
}

function Row(props) {

  const onMouseEnter = ({ target }) =>
    props.onHoverRow(true, props.index)

  const onMouseLeave = ({ target }) =>
    props.onHoverRow(false, props.index)

  console.log("Rendering row")

  return (
    <tr onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} key={props.id}>
      <td style={{ border: "1px solid red" }}>---------------- {props.id}</td>
      <td
        ref={(ref) => props.onAddRow(ref, props.index)}
        style={{ border: "1px solid green" }}
      ></td>
    </tr>
  )
}

Code sandbox: https://codesandbox.io/s/bypass-react-1b-rsqtq

You can see for yourself in the console that each component only renders once.

Conclusion

Always first implement things inside React the usual way, then use memo , useCallback , useMemo and refs to improve performance where absolutely necessary. Remember that more complicated code also comes at a cost so just because you're saving some rerenderings with React doesn't mean you have arrived at a better solution.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM