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:
isHovered
is now true
allowHover
changes to true
allowHover
and isHovered
are both true
, show extra row contentThe sequence for mouse OUT the table:
isHovered
is set to false
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>
);
}
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.
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.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.
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.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.
Basically, whether a Row
should be visible or not depends on two conditions:
allowHover.current
set to true
?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.
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
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 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 component only renders once.
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.
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 components 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.