react-canvas-race-conditions

star 3

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.

TradersPost By TradersPost schedule Updated 5/21/2026

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:

  1. WebSocket subscribes (useEffect with [symbol, granularity] deps)
  2. 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
  3. Bars fetch resolves (async, completed after the WS connection)
  4. Bars-loaded useEffect calls series.setData(historical) → chart restored to correct OHLC
  5. 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 let inside useEffect

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

  • useEffect closures capture stale values for parent state. That's exactly the race — when the WS handler captures a value of initialBar (or any prop) at mount time, subsequent updates to that prop don't propagate. Reading via someRef.current instead 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, initialBar is 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's current is independent — you must reset it separately. Forgetting this is the bug.
  • Don't reset currentRef.current on EVERY prop change. Only when initialBar goes 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 useBarsStream hook (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 preserve
  • browser-verify-ui-changes — this class of bug only shows up at paint time
  • lightweight-charts-integration — for the series.update() / series.setData() semantics this skill orbits around
Install via CLI
npx skills add https://github.com/TradersPost/traderspost-command-dash --skill react-canvas-race-conditions
Repository Details
star Stars 3
call_split Forks 3
navigation Branch main
article Path SKILL.md
More from Creator