Wick Charts

Multi-chart Sync

Three charts — price, volume, and RSI — sharing one visible time range. Hover any pane and all three show their own tooltip + crosshair at the same time x, as if the cursor were on all of them at once. Pan or zoom any chart and the other two follow. A re-entrancy guard inside the broadcast hook keeps per-gesture cost at O(n), so the pattern scales to many charts.
BTC/USDPrice
100102104106108110
May 28
VolumeVolume
020000
May 28
RSI14-period
50100
May 28
01 — REGISTER EACH CHART
Every <ChartContainer> mounts its own ChartInstance. Capture each one with a small helper inside the container that reads useChartInstance() and lifts it to parent state — refs won't trigger the rerender that lets the sync effect rebind.
function RegisterChart({ onReady }) {
const chart = useChartInstance();
useEffect(() => {
onReady(chart);
return () => onReady(null);
}, [chart, onReady]);
return null;
}
 
// Parent: state, not refs — so this component re-renders when
// each chart mounts and the sync effect can rebind.
const [priceChart, setPriceChart] = useState(null);
const [volumeChart, setVolumeChart] = useState(null);
const [rsiChart, setRsiChart] = useState(null);
02 — BROADCAST WITH RE-ENTRANCY GUARD
Every chart's event fans out to peers via apply(src, dst). A broadcasting flag in the effect closure shorts cascade emits: when peer.setX inside the loop fires its own event, the receiver's handler sees the flag set and returns before iterating peers. Per-gesture cost is O(n) — n − 1 real applies plus n − 1 short-circuited invocations. No idempotency dependency; setVisibleRange / setCrosshair stay idempotent as defense in depth.
function useBroadcast(charts, event, apply) {
useEffect(() => {
const ready = charts.filter(Boolean);
let broadcasting = false;
 
const handlers = ready.map((source) => {
const onEvent = () => {
if (broadcasting) return;
broadcasting = true;
try {
for (const other of ready) {
if (other !== source) apply(source, other);
}
} finally {
broadcasting = false;
}
};
source.on(event, onEvent);
return { source, onEvent };
});
 
return () => {
for (const { source, onEvent } of handlers) {
source.off(event, onEvent);
}
};
}, [...charts, event, apply]);
}
03 — VIEWPORT + CROSSHAIR WRAPPERS
Two thin wrappers around useBroadcast. apply functions live as module-level constants — stable identity, no useCallback dance, no listener re-binds on parent re-renders. Pan/zoom any chart and the rest follow; hover any pane and every pane's native <Crosshair> + <Tooltip> tracks the same time x. The { gesture: true } flag on setVisibleRange eases the synced panes on the same fast profile as a hand pan/zoom, so their Y axes re-fit in lockstep instead of crawling behind on the slower programmatic settle.
// Module scope:
const applyViewport = (src, dst) => {
// gesture: true — ease in lockstep with the driving pan/zoom
// instead of the slower programmatic (sticky-Y) settle.
dst.setVisibleRange(src.getVisibleRange(), { gesture: true });
};
 
const applyCrosshair = (src, dst) => {
const pos = src.getCrosshairPosition();
dst.setCrosshair(pos ? { time: pos.time } : null);
};
 
function useSyncViewport(charts) {
useBroadcast(charts, 'viewportChange', applyViewport);
}
 
function useSyncCrosshair(charts) {
useBroadcast(charts, 'crosshairMove', applyCrosshair);
}
 
// Parent:
useSyncViewport(charts);
useSyncCrosshair(charts);