name: react-canvas-race-conditions
description: Use when two async streams (HTTP bars-fetch + WebSocket tick-stream) update a single canvas chart instance and you see partial-bar overwrites, "the chart wipes itself on first tick," or stale state surviving a setData call. The fix is lifting handler state from a closed-over let to a useRef, and resetting that ref whenever the prop signaling "new historical loaded" changes.
metadata:
category: charting
React + canvas race conditions in chart UIs
When a chart consumes data from two sources — historical bars via HTTP fetch, live ticks via WebSocket — and you embed it in React, the most common bug pattern is:
- WebSocket subscribes (
useEffectwith[symbol, granularity]deps) - First WS tick fires. Internal "have we seen any data?" state is null → take the "fresh bar" branch → state set to
(p, p, p, p)from the tick price - Bars fetch resolves (async, completed after the WS connection)
- Bars-loaded
useEffectcallsseries.setData(historical)→ chart restored to correct OHLC - Next WS tick fires. Internal state still says
(p, p, p, p)from step 2. State update branch overwrites the just-restored bar's OHLC with the bogus baseline
The chart silently flickers between "correct from setData" and "wrong from tick," visually settling on the wrong values within milliseconds.
This bug is invisible to unit tests because the test harness doesn't fire WS ticks. It's invisible to getBoundingClientRect-style e2e checks because the canvas reports the right size and the right number of bars. Only paint-time comparison catches it — see browser-verify-ui-changes.
When to use
Reach for this skill if you're:
- Building a chart with a
useTickStream-style hook and seeing flicker on page-load - Mounting a second chart (e.g. picture-in-picture) whose bars come from
fetch(not server props) - Observing "first WS tick wipes the partial bar" symptoms (see
partial-bar-graft) - Adding a hook that captures state in a
letinsideuseEffect
The pattern — lift state to refs, reset on the trigger prop
The bug is that the hook's "current bar" state is a local let inside the WS-subscribe effect's closure. Nothing outside that closure can reset it. When the historical bars finally arrive and the parent calls setData, the canvas DISPLAY is fixed but the HOOK's internal current still points to the bogus first-tick state.
// BEFORE — broken
useEffect(() => {
if (!symbol) return;
let current: LiveBar | null = null; // local; no outside reset
ws.onmessage = (ev) => {
if (!current) {
const seed = initialBarRef.current;
if (seed && seed.time === barTime) {
current = mergeSeed(seed, tick);
} else {
current = freshBar(tick); // bug branch when seed is null
}
} else if (current.time === barTime) {
current = updateInPlace(current, tick); // wipes the just-restored bar
}
callback(current);
};
}, [symbol, granularity]);
// AFTER — lift `current` to a ref + reset on initialBar prop change
const currentRef = useRef<LiveBar | null>(null);
useEffect(() => {
initialBarRef.current = initialBar ?? null;
// Bars just (re-)loaded with a new partial bar — drop the hook's
// accumulated state so the next tick re-seeds from the freshly
// loaded historical.
if (initialBar) currentRef.current = null;
}, [initialBar]);
useEffect(() => {
if (!symbol) return;
currentRef.current = null; // also reset on subscription reset
ws.onmessage = (ev) => {
const current = currentRef.current;
let next: LiveBar;
if (!current) {
const seed = initialBarRef.current;
initialBarRef.current = null;
next = (seed && seed.time === barTime) ? mergeSeed(seed, tick) : freshBar(tick);
} else if (current.time !== barTime) {
next = freshBar(tick);
} else {
next = updateInPlace(current, tick);
}
currentRef.current = next;
callback(next);
};
}, [symbol, granularity]);
Why this works
The new currentRef is the single source of truth for "what bar is the hook accumulating into." Critically, the [initialBar] effect can null it out — telling the next tick "treat yourself as a first tick and re-read the seed." When bars finally load and the parent sets the initialBar prop, the next tick will read the freshly-loaded historical OHLC instead of the stale bogus state.
The unit tests for the reducer still pass because the OHLC transitions are unchanged — only the storage is in a ref instead of a closed-over let.
Gotchas
useEffectclosures capture stale values for parent state. That's exactly the race — when the WS handler captures a value ofinitialBar(or any prop) at mount time, subsequent updates to that prop don't propagate. Reading viasomeRef.currentinstead does propagate, because refs are mutable.- Server-rendered props don't have this race. If your bars arrive as a React Server Component prop on first render,
initialBaris non-null before any tick. Only client-fetched bars (e.g.useBarsStream-style hooks) hit the race. - DOM clear vs hook reset are two separate things.
series.setData(historical)clears and re-fills the CANVAS. The HOOK'scurrentis independent — you must reset it separately. Forgetting this is the bug. - Don't reset
currentRef.currenton EVERY prop change. Only wheninitialBargoes from null to non-null (or to a different time). Resetting on every render would lose live state during normal tick processing. - The bus / event approach is a tempting alternative but worse. "Emit a 'bars loaded' event that the hook listens for" works but adds a second event surface for the same thing the prop already communicates. Just use the prop.
Reference implementation
This came from a charting project with two chart consumers:
- A main chart whose bars arrived as a Server Component prop (no race; the prop was non-null on first render)
- A picture-in-picture chart fetching its own bars via a
useBarsStreamhook (race hit here)
Both consumers share a useTickStream hook that owns the WebSocket subscription. Lifting current to a ref + resetting on the [initialBar] effect was the minimal change that fixed the PiP without disturbing the main chart's working flow. Existing reducer unit tests covered the OHLC transitions and continued to pass after the ref lift.
Related skills
partial-bar-graft— the bug only manifests when there's a partial bar to graft and preservebrowser-verify-ui-changes— this class of bug only shows up at paint timelightweight-charts-integration— for theseries.update()/series.setData()semantics this skill orbits around