最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

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

programmeradmin1浏览0评论

tl;dr - how to force re-render only one specific child ponent 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 ponent 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 ponent?
  }, [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>
  );
}

tl;dr - how to force re-render only one specific child ponent 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 ponent 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 ponent?
  }, [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>
  );
}
Share Improve this question asked Dec 25, 2021 at 7:26 McDerpMcDerp 1571 gold badge3 silver badges16 bronze badges 11
  • Is the goal to avoid rendering the whole table when a single row needs to update? If so, have you considered using React.memo? – Raphael Rafatpanah Commented Dec 25, 2021 at 7:43
  • @RaphaelRafatpanah If I set "allowHover" as a prop, then every time the cursor enters/exits the table, that prop will change for every child element. That's what I'm trying to avoid - am I missing something? Hover over row = do something to only that row. Hover over whole table = re-render all rows because the table's state is now changed. – McDerp Commented Dec 25, 2021 at 7:53
  • I'm not 100% understanding your use case, but I'll say that if you're looking to avoid re-rendering, then consider using React.memo to prevent a re-render based on current and next props. You may need to re-structure some ponents such that you're able to prevent as much of the table from re-rendering as possible (or until performance is good). Lastly, if you don't have any performance issues now, then I'd avoid these optimizations. – Raphael Rafatpanah Commented Dec 25, 2021 at 7:56
  • I'm aware of memo, but it's not useful here as this parent state change needs to propagate down to all children. If I could avoid notifying all children of the change, memo would be great, but that's not the case here. Imagine if I had window.allowHover - is there a way to make an individual row react to that? I'm passing the equivalent of that through props but useEffect doesn't do anything when the value changes. How can I re-render just that element without changing parent state (and thus rerendering everything, every time)? – McDerp Commented Dec 25, 2021 at 8:01
  • With React.memo, you can let the parent state change and prevent each row from re-rendering – Raphael Rafatpanah Commented Dec 25, 2021 at 19:54
 |  Show 6 more ments

1 Answer 1

Reset to default 6 +50

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 pares 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 ponent 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 ponent 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 ponent 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 acplish 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 ponent 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 ponent / 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 ponents. You can acplish this by either merging the code of Table, Row and AllRows into one ponent or pass refs from the children back up to the parent ponent 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 Rows will rerender whenever isHovered for that Row is changed. The result is that Rows 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 [currentRow, setCurrentRow] = useState(null);
  const hoverTimeout = useRef(null);

  const onHover = (id) => setCurrentRow(id);

  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
      setCurrentRow(null);
    }, 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 onHover={onHover} currentRow={allowHover ? currentRow : null} />
        </tbody>
      </table>
    </div>
  );
}

function Rows(props) {

  console.log("Rendering rows");

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

function Row(props) {

  console.log("Rendering row");

  return (
    <tr key={props.id} onMouseEnter={props.onHover}>
      <td style={{ border: "1px solid red" }}>---------------- {props.id}</td>
      <td style={{ border: "1px solid green" }}>
        {props.isActive ? <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 remended, 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 Rows to pass their refs back up to the Table ponent which manually updates the Rows via refs. This is pretty much what React does under the hood actually.

Here, we add a lot of logic to the Table ponent which bees more plicated but instead, the Row ponents lose some code:

function Table() {

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

  const onAddRow = (row, id) => {
    rows.current = {
      ...rows.current,
      [id]: row
    };
    onUpdate();
  };

  const onHoverRow = (id) => {
    currentRow.current = id.toString();
    onUpdate();
  };

  const onUpdate = () => {
    Object.keys(rows.current).forEach((key) => {
      if (key === currentRow.current && allowHover.current) {
        rows.current[key].innerHTML = "<button>Accept</button>";
      } else {
        rows.current[key].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
      currentRow.current = null;
      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) => (
    <Row
      key={id}
      id={id}
      onAddRow={props.onAddRow}
      onHoverRow={() => props.onHoverRow(id)}
    />
  ));
}

function Row(props) {

  console.log("Rendering row");

  return (
    <tr onMouseEnter={props.onHoverRow} key={props.id}>
      <td style={{ border: "1px solid red" }}>---------------- {props.id}</td>
      <td
        ref={(ref) => props.onAddRow(ref, props.id)}
        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 ponent 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 plicated code also es at a cost so just because you're saving some rerenderings with React doesn't mean you have arrived at a better solution.

Update

I changed the code so that once a row has been hovered, it is registered as the current hovered row. This row will then not stop being hovered until another row is hovered or until the table has been disabled (1 s after mouse leaves table). A tiny problem here is that we may enter the table but NOT hover a row so that we are inside the table without any active row. This also means that if we leave the table in this state, no row will be "active" since none was active to begin with. This is due to that there is extra room in the table that is not allocated to any row. It works well despite this but it's good to be aware of. Alas, tables are not really layout ponents and the more particular your layout gets, the more this shows. If this is a problem, I would use flex-box instead or a table without cell spacing and borders. If borders are required, they could be added inside each cell with absolute positioning instead of relying on the table's spacing and borders.

发布评论

评论列表(0)

  1. 暂无评论