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:
- Hover over row
- Row's
isHovered
is nowtrue
- In 1000ms,
allowHover
changes totrue
- Since
allowHover
andisHovered
are bothtrue
, show extra row content
The sequence for mouse OUT the table:
- Mouse moves outside of the parent container/table/row
- Previously hovered row's
isHovered
is set tofalse
- Previously hovered row's hidden content is hidden
- In 1000ms,
allowHover
changes tofalse
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:
- Hover over row
- Row's
isHovered
is nowtrue
- In 1000ms,
allowHover
changes totrue
- Since
allowHover
andisHovered
are bothtrue
, show extra row content
The sequence for mouse OUT the table:
- Mouse moves outside of the parent container/table/row
- Previously hovered row's
isHovered
is set tofalse
- Previously hovered row's hidden content is hidden
- In 1000ms,
allowHover
changes tofalse
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 butuseEffect
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
1 Answer
Reset to default 6 +50Basics
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'scurrent
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. TheuseEffect
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
inRow
is not triggered whenever you change the content of the ref. Well, this is simply becauseuseEffect
runs after render, and there is no guarantee that the ponent will rerender just because you change the content of theallowHover
ref. The ref passed toAllRows
andRow
is the same property all the time (only itscurrent
property changes), therefore they will never rerender due to props being changed. SinceRow
only rerenders, by itself, whenisHovered
is set, there is no guarantee that theuseEffect
will fire just because you change the content ofallowHover
ref. WHENRow
rerenders, the effect will run IF the value ofallowHover.current
is different from last time. - Using
memo
will not help you here either sinceTable
orAllRows
don't rerender either.memo
allows us to skip rerendering children when parents rerender, but here, the parents don't rerender, somemo
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 totrue
? - Is
isHovered
set totrue
?
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 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 [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 Row
s to pass their refs back up to the Table
ponent 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
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, table
s 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.