A React dashboard stores the cursor position in state on the page component so a tooltip can follow the mouse, and every mousemove re-renders a heavy chart and table. Without memoizing the chart or the table, how would you restructure the components so only the tooltip updates?
answer
- scope before frequency
- smallest possible state owner
- sibling, not ancestor, of the heavy tree
- relay the dashboard as children
- per-frame pixels need no render at all
basics
~20 sIsolate the high-frequency state in its own leaf component that owns the mousemove listener and renders only the tooltip. If that component must visually wrap the dashboard, have it take the chart and table as children so those elements keep their identity across its updates.
solid answer
~50 sThe chart re-renders because the state lives in the page, and an update re-renders its owner's whole subtree — sixty times a second, that is the entire dashboard. Restructure so the fast state has the smallest possible owner: a `MouseTooltip` component that attaches the listener, holds the coordinates, and renders just the tooltip, rendered as a sibling of the chart. Now a mousemove re-renders one small component. If the tooltip needs to be positioned relative to the dashboard and must wrap it, keep the state in that wrapper but let it accept the dashboard as `children` from a parent that is not re-rendering — the relayed elements are the same objects each time, so React bails out on them. For genuinely per-frame visuals you can go further and write the position straight to a node through a ref, doing no React render at all. Throttling alone is the weak answer: it lowers the frequency of an oversized render instead of shrinking it.
code
jsx · 16 linesimport { useEffect, useState } from 'react';
export function MouseTooltip({ label }) {
const [pos, setPos] = useState(null);
useEffect(() => {
const onMove = e => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
if (!pos) return null;
return (
<div style={{ position: 'fixed', left: pos.x, top: pos.y }}>{label}</div>
);
}go deeper
Know that a state update re-renders the component holding the state and everything below it, so putting a mouse position at the top of a page is expensive by construction.
Explain both structural levers and when each applies: move the state into a leaf component, or keep it put and relay the heavy subtree as children so its elements keep identity.
Show the production judgment — diagnose from a profile that the page is the state owner, restructure rather than memoize, and know when a per-frame value should bypass React and be written to a node through a ref.
Own the guardrail: high-frequency inputs such as pointer, scroll and resize must never be stored above expensive regions, and any escape into direct DOM writes needs a stated boundary so it does not spread into state the app derives from.
## Why the whole dashboard repaints The rule that decides everything here: a state update re-renders the component that owns the state, and then the components it renders, until React can bail out. Cursor coordinates in the page component mean the page is the owner, and the chart and table are in its subtree. Pointer moves fire at roughly display refresh rate, so the app is asking React to walk the whole dashboard tens of times a second for a two-number change. Notice what is *not* the problem: the event system, batching, or the chart itself. React batches these updates fine and the chart renders at a perfectly normal speed — just far too often. The defect is structural. ## Step 1 — give the fast state the smallest owner you can Move both the listener and the state into a dedicated component whose entire output is the tooltip. ```jsx function MouseTooltip({ label }) { const [pos, setPos] = useState(null); useEffect(() => { const onMove = e => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', onMove); return () => window.removeEventListener('mousemove', onMove); }, []); if (!pos) return null; return <div style={{ position: 'fixed', left: pos.x, top: pos.y }}>{label}</div>; } ``` Rendered as a sibling of the chart, this component's updates cannot reach the chart: the chart is not in its subtree. That single move usually ends the problem, and it costs no cache, no comparator and no dependency array. ## Step 2 — if it must wrap, relay the content Sometimes the tooltip owner also has to be the positioned container, so the dashboard has to live inside it. Then keep the state where it is but stop *authoring* the dashboard there: ```jsx <TooltipLayer label="details"> <Chart data={data} /> <ResultsTable rows={rows} /> </TooltipLayer> ``` `TooltipLayer` owns the coordinates and renders `{children}`. Because those elements are created in a parent that is not re-rendering, `props.children` is the identical object on every mousemove update, and React bails out on the whole relayed subtree. The state stayed high; the *authorship* of the expensive tree moved up. That is the same lever as moving state down, applied from the other side. ## Step 3 — for true per-frame visuals, leave React out Coordinates that change every frame and only move pixels do not need to be state at all. Keep a ref to the tooltip node and write the position directly in the listener: ```jsx const nodeRef = useRef(null); useEffect(() => { const onMove = e => { const node = nodeRef.current; if (node) node.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`; }; window.addEventListener('mousemove', onMove); return () => window.removeEventListener('mousemove', onMove); }, []); ``` No render, no reconciliation — just a style write. Use this deliberately and sparingly: the value is now invisible to React, so nothing else may derive from it, and it must not affect anything semantic or accessible. Drag handles, cursor followers and scroll-linked ornamentation are the honest use cases. ## The answers that sound plausible and are worse - **Throttle the state update.** Fewer oversized renders is still oversized renders, and the tooltip now visibly lags the cursor. Frequency is the symptom; scope is the disease. - **Wrap the chart and table in `React.memo`.** It works until someone passes an inline object or a fresh callback, then it silently stops, and every prop of both components becomes something reviewers must keep stable forever. - **Put the coordinates in a context provider at the page root.** Strictly worse: now every consumer under the provider re-renders on each mousemove, and the value is a new object every time. ## Verifying the fix Record a profile while moving the mouse and look at what appears in each commit. Success is a commit list containing the tooltip and nothing else; if the chart is still in there, the state has not actually left its subtree — usually because a coordinate is still being read one level too high, or the parent re-renders for its own reasons and re-creates the relayed elements.
- When would you skip React state entirely for a value like this?When it changes every frame and only moves pixels: keep a ref to the node and set its `style.transform` inside the listener. No render happens at all. The tradeoff is that React no longer knows the value, so nothing may derive from it, and it must not carry semantic or accessible meaning.
- The tooltip has to be positioned relative to the dashboard, so it must wrap it. Does that force the cost back?No. Keep the coordinates in the wrapper but let it accept the dashboard as `children` from a parent that is not re-rendering. The relayed elements are the same objects on every pointer update, so React bails out on that subtree while the wrapper re-renders freely.
- Would putting the coordinates in a context provider at the page root help?It makes things worse. Every component reading that context re-renders on each pointer move, and the provider's value object is recreated each time, so consumers cannot bail out. Context distributes a value; it does not narrow render scope.
saying these in an interview costs you the question
- Throttles the update and calls the render scope fixed
- Blames the chart's render speed rather than the state's location
- Says React skips work automatically when only two numbers change
- Wraps everything in React.memo as the first move
- Puts the cursor position in a root-level context provider