Skip to content

Lightweight Charts — a complex solution for performant charts

reactperformancecanvas

Every charting library in the React ecosystem makes the same pitch: declarative API, easy integration, beautiful defaults. And for dashboards with a few hundred data points, they deliver. But the moment you need to render 10,000+ candlesticks with real-time WebSocket updates at 60fps, those abstractions collapse. Virtual DOM diffing was never designed for this.

After hitting the wall with Recharts on a crypto trading dashboard — where re-renders on every tick caused visible jank — I moved to TradingView's Lightweight Charts. It's an HTML5 canvas library with a single dependency (fancy-canvas), sub-megabyte footprint, and it handles its own rendering loop entirely outside of React's reconciliation. The tradeoff is that its API is entirely imperative. You're calling chart.addCandlestickSeries(), series.setData(), and chart.timeScale().fitContent() — not passing props.

This article is about bridging that gap. How to take an imperative canvas library and wrap it in React hooks and components that feel native, without losing the performance that made you reach for it in the first place.


Why not Recharts / Chart.js / Nivo?

This isn't a "Recharts bad" take. Those libraries are excellent for what they do. But they share a fundamental constraint: their React wrappers couple rendering to React's reconciliation cycle — Recharts and Nivo render SVG nodes in the virtual DOM, while Chart.js (via react-chartjs-2) triggers a full canvas repaint on every prop change. That means:

  • Re-renders cascade. A parent state change causes the chart component to reconcile, even if the chart data hasn't changed.
  • Large datasets choke the diffing algorithm. 10,000 SVG <rect> elements in a virtual DOM is a performance cliff.
  • Real-time updates require workarounds. You end up fighting React.memo, useMemo, and ref-based escape hatches to prevent full re-renders on every incoming tick.

Lightweight Charts sidesteps all of this. It owns a <canvas> element, manages its own rendering pipeline, and you talk to it through a stable API reference. React doesn't know or care what's happening on that canvas — which is exactly the point.


Installation

npm install lightweight-charts

That's it. No peer dependencies, no CSS imports, no provider wrappers.

Note: The code examples in this article use the Lightweight Charts v4 API. In v5, individual methods like addCandlestickSeries() and addLineSeries() were replaced with a unified addSeries(CandlestickSeries, options) pattern. The architectural patterns and React integration strategy remain the same across versions.


The core hook: useChart

The foundational pattern is a ref-based hook that creates the chart on mount and destroys it on unmount. Every other feature builds on top of this.

import { useRef, useEffect, useCallback } from "react";
import {
  createChart,
  type IChartApi,
  type DeepPartial,
  type ChartOptions,
} from "lightweight-charts";

interface UseChartOptions {
  options?: DeepPartial<ChartOptions>;
}

function useChart({ options = {} }: UseChartOptions = {}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<IChartApi | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const chart = createChart(container, {
      width: container.clientWidth,
      height: container.clientHeight,
      layout: {
        background: { color: "transparent" },
        textColor: "#9ca3af",
      },
      grid: {
        vertLines: { color: "#1f2937" },
        horzLines: { color: "#1f2937" },
      },
      crosshair: {
        mode: 0, // Normal crosshair
      },
      rightPriceScale: {
        borderColor: "#374151",
      },
      timeScale: {
        borderColor: "#374151",
        timeVisible: true,
      },
      ...options,
    });

    chartRef.current = chart;

    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        chart.resize(width, height);
      }
    });

    resizeObserver.observe(container);

    return () => {
      resizeObserver.disconnect();
      chart.remove();
      chartRef.current = null;
    };
  }, []); // intentionally empty — options are applied once on mount

  const getChart = useCallback(() => chartRef.current, []);

  return { containerRef, getChart };
}

A few things to note:

  1. ResizeObserver instead of window.resize. The chart needs to resize when its container changes, not when the window changes. A sidebar toggle, a collapsible panel — these don't fire window.resize. ResizeObserver catches all of them.

  2. Stable ref, not state. The chart instance lives in a ref, not in useState. Storing it in state would cause a re-render on creation, which is pointless — no component needs to re-render because a chart was initialized.

  3. Empty dependency array. The chart is created once. If you need to change options after mount, use chart.applyOptions() — don't destroy and recreate. That's the imperative model.


A minimal candlestick chart

With the hook in place, rendering a candlestick chart is straightforward:

import { useRef, useEffect, useCallback } from "react";
import {
  createChart,
  type IChartApi,
  type ISeriesApi,
  type CandlestickData,
  type DeepPartial,
  type ChartOptions,
  type Time,
} from "lightweight-charts";

// — paste useChart from the previous section, or import from your own module —

function useChart(options: DeepPartial<ChartOptions> = {}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<IChartApi | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const chart = createChart(container, {
      width: container.clientWidth,
      height: container.clientHeight,
      layout: {
        background: { color: "transparent" },
        textColor: "#9ca3af",
      },
      grid: {
        vertLines: { color: "#1f2937" },
        horzLines: { color: "#1f2937" },
      },
      rightPriceScale: { borderColor: "#374151" },
      timeScale: { borderColor: "#374151", timeVisible: true },
      ...options,
    });

    chartRef.current = chart;

    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      chart.resize(width, height);
    });
    ro.observe(container);

    return () => {
      ro.disconnect();
      chart.remove();
      chartRef.current = null;
    };
  }, []);

  return { containerRef, getChart: useCallback(() => chartRef.current, []) };
}

// ---- example starts here ----

const SAMPLE_DATA: CandlestickData<Time>[] = [
  { time: "2024-01-02", open: 141.25, high: 145.88, low: 140.10, close: 144.50 },
  { time: "2024-01-03", open: 144.50, high: 146.92, low: 143.78, close: 145.20 },
  { time: "2024-01-04", open: 145.20, high: 147.55, low: 141.30, close: 142.10 },
  { time: "2024-01-05", open: 142.10, high: 144.80, low: 140.50, close: 143.90 },
  { time: "2024-01-08", open: 143.90, high: 148.20, low: 143.10, close: 147.80 },
  { time: "2024-01-09", open: 147.80, high: 150.30, low: 146.90, close: 149.50 },
  { time: "2024-01-10", open: 149.50, high: 151.00, low: 147.20, close: 148.10 },
  { time: "2024-01-11", open: 148.10, high: 149.90, low: 145.60, close: 146.30 },
  { time: "2024-01-12", open: 146.30, high: 148.75, low: 144.80, close: 147.90 },
  { time: "2024-01-16", open: 147.90, high: 152.40, low: 147.10, close: 151.80 },
  { time: "2024-01-17", open: 151.80, high: 153.10, low: 149.70, close: 150.20 },
  { time: "2024-01-18", open: 150.20, high: 154.60, low: 149.90, close: 153.80 },
  { time: "2024-01-19", open: 153.80, high: 155.20, low: 152.10, close: 154.50 },
  { time: "2024-01-22", open: 154.50, high: 156.80, low: 153.40, close: 155.90 },
  { time: "2024-01-23", open: 155.90, high: 157.30, low: 154.20, close: 156.10 },
];

function CandlestickChart() {
  const { containerRef, getChart } = useChart();
  const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);

  useEffect(() => {
    const chart = getChart();
    if (!chart) return;

    const series = chart.addCandlestickSeries({
      upColor: "#22c55e",
      downColor: "#ef4444",
      borderDownColor: "#ef4444",
      borderUpColor: "#22c55e",
      wickDownColor: "#ef4444",
      wickUpColor: "#22c55e",
    });

    series.setData(SAMPLE_DATA);
    seriesRef.current = series;

    chart.timeScale().fitContent();

    return () => {
      chart.removeSeries(series);
      seriesRef.current = null;
    };
  }, [getChart]);

  return (
    <div
      ref={containerRef}
      style={{ width: "100%", height: 400 }}
    />
  );
}

The series ref follows the same pattern as the chart ref — stable reference, no state, no re-renders. The cleanup function calls removeSeries so hot module replacement in development doesn't stack duplicate series on top of each other.


Real-time updates with WebSocket

This is where Lightweight Charts earns its name. Updating a chart with a new tick is a single method call — series.update() — and it doesn't touch React at all. No state update, no reconciliation, no diffing. The canvas repaints in the library's own animation frame.

import { useEffect, useRef } from "react";
import {
  type IChartApi,
  type ISeriesApi,
  type CandlestickData,
  type Time,
} from "lightweight-charts";

function useRealtimeCandlestick(
  getChart: () => IChartApi | null,
  wsUrl: string,
  initialData: CandlestickData<Time>[]
) {
  const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);

  useEffect(() => {
    const chart = getChart();
    if (!chart) return;

    const series = chart.addCandlestickSeries({
      upColor: "#22c55e",
      downColor: "#ef4444",
      borderDownColor: "#ef4444",
      borderUpColor: "#22c55e",
      wickDownColor: "#ef4444",
      wickUpColor: "#22c55e",
    });

    series.setData(initialData);
    seriesRef.current = series;
    chart.timeScale().fitContent();

    const ws = new WebSocket(wsUrl);

    ws.addEventListener("message", (event) => {
      const tick = JSON.parse(event.data) as CandlestickData<Time>;
      // update() merges into existing candle if time matches,
      // or appends a new candle if it's a new time bucket
      series.update(tick);
    });

    return () => {
      ws.close();
      chart.removeSeries(series);
      seriesRef.current = null;
    };
  }, [getChart, wsUrl, initialData]);

  return seriesRef;
}

// Stable empty array — avoids a new reference on every render,
// which would re-trigger the effect and reconnect the WebSocket.
const EMPTY_CANDLES: CandlestickData<Time>[] = [];

// Usage
function LiveChart({ symbol }: { symbol: string }) {
  const { containerRef, getChart } = useChart();

  useRealtimeCandlestick(
    getChart,
    `wss://stream.example.com/ws/${symbol}`,
    EMPTY_CANDLES // or pass real initial data from a REST fetch
  );

  return <div ref={containerRef} style={{ width: "100%", height: 500 }} />;
}

The key insight: the WebSocket message handler calls series.update() directly on the ref. No setState, no dispatch, no re-render. This is why the chart stays at 60fps even with ticks arriving multiple times per second. React is completely out of the loop — and that's the correct architecture for this kind of rendering.

Compare this to the Recharts equivalent, where every tick would require setData(prev => [...prev, newPoint]), triggering a full component re-render, virtual DOM diff on thousands of SVG nodes, and a DOM update. At 4 ticks per second with 10,000 data points, that's untenable.


Multiple series and overlays

Real trading charts aren't just candlesticks. You need volume bars, moving averages, and indicators overlaid on the same time axis. Lightweight Charts handles this by attaching multiple series to a single chart, each with its own price scale if needed.

import { useEffect, useRef } from "react";
import {
  type ISeriesApi,
  type CandlestickData,
  type HistogramData,
  type LineData,
  type Time,
} from "lightweight-charts";

function sma(data: CandlestickData<Time>[], period: number): LineData<Time>[] {
  const result: LineData<Time>[] = [];

  for (let i = period - 1; i < data.length; i++) {
    let sum = 0;
    for (let j = 0; j < period; j++) {
      sum += data[i - j].close;
    }
    result.push({
      time: data[i].time,
      value: sum / period,
    });
  }

  return result;
}

function toVolumeData(
  candles: CandlestickData<Time>[],
  volumes: number[]
): HistogramData<Time>[] {
  return candles.map((c, i) => ({
    time: c.time,
    value: volumes[i],
    color: c.close >= c.open ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)",
  }));
}

function ChartWithIndicators({
  candles,
  volumes,
}: {
  candles: CandlestickData<Time>[];
  volumes: number[];
}) {
  const { containerRef, getChart } = useChart();
  const refs = useRef<{
    candleSeries: ISeriesApi<"Candlestick"> | null;
    volumeSeries: ISeriesApi<"Histogram"> | null;
    sma20Series: ISeriesApi<"Line"> | null;
    sma50Series: ISeriesApi<"Line"> | null;
  }>({
    candleSeries: null,
    volumeSeries: null,
    sma20Series: null,
    sma50Series: null,
  });

  useEffect(() => {
    const chart = getChart();
    if (!chart) return;

    // Candlestick series — main price scale
    const candleSeries = chart.addCandlestickSeries({
      upColor: "#22c55e",
      downColor: "#ef4444",
      borderDownColor: "#ef4444",
      borderUpColor: "#22c55e",
      wickDownColor: "#ef4444",
      wickUpColor: "#22c55e",
    });
    candleSeries.setData(candles);

    // Volume histogram — separate price scale at the bottom
    const volumeSeries = chart.addHistogramSeries({
      priceFormat: { type: "volume" },
      priceScaleId: "volume",
    });
    volumeSeries.priceScale().applyOptions({
      scaleMargins: { top: 0.8, bottom: 0 },
    });
    volumeSeries.setData(toVolumeData(candles, volumes));

    // SMA 20 — overlaid on main price scale
    const sma20Series = chart.addLineSeries({
      color: "#3b82f6",
      lineWidth: 1,
      priceLineVisible: false,
      lastValueVisible: false,
    });
    sma20Series.setData(sma(candles, 20));

    // SMA 50
    const sma50Series = chart.addLineSeries({
      color: "#f59e0b",
      lineWidth: 1,
      priceLineVisible: false,
      lastValueVisible: false,
    });
    sma50Series.setData(sma(candles, 50));

    refs.current = { candleSeries, volumeSeries, sma20Series, sma50Series };

    chart.timeScale().fitContent();

    return () => {
      chart.removeSeries(candleSeries);
      chart.removeSeries(volumeSeries);
      chart.removeSeries(sma20Series);
      chart.removeSeries(sma50Series);
    };
  }, [getChart, candles, volumes]);

  return <div ref={containerRef} style={{ width: "100%", height: 500 }} />;
}

The volume histogram uses a separate priceScaleId with scaleMargins pushing it to the bottom 20% of the chart. The SMA lines share the main price scale with the candlesticks, so they overlay naturally. All four series share the same time axis and respond to zoom/pan as a unit.

The SMA function is intentionally naive — a simple sliding window. In production you'd compute this server-side or use a streaming algorithm, but the rendering pattern is the same regardless of how the data arrives.


Custom tooltip with React

Here's where the imperative and declarative worlds collide. Lightweight Charts provides a subscribeCrosshairMove callback with the cursor position and series data at that point. We can pipe that into React state to render a tooltip with our own components.

import { useEffect, useState, useRef } from "react";
import {
  type IChartApi,
  type ISeriesApi,
  type MouseEventParams,
  type CandlestickData,
  type Time,
} from "lightweight-charts";

// assumes useChart() from the "core hook" section above

interface TooltipData {
  x: number;
  y: number;
  open: number;
  high: number;
  low: number;
  close: number;
  time: string;
}

function ChartWithTooltip({
  candles,
}: {
  candles: CandlestickData<Time>[];
}) {
  const { containerRef, getChart } = useChart();
  const [tooltip, setTooltip] = useState<TooltipData | null>(null);
  const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);

  useEffect(() => {
    const chart = getChart();
    if (!chart) return;

    const series = chart.addCandlestickSeries({
      upColor: "#22c55e",
      downColor: "#ef4444",
      borderDownColor: "#ef4444",
      borderUpColor: "#22c55e",
      wickDownColor: "#ef4444",
      wickUpColor: "#22c55e",
    });
    series.setData(candles);
    seriesRef.current = series;
    chart.timeScale().fitContent();

    const handler = (param: MouseEventParams<Time>) => {
      if (!param.point || !param.time) {
        setTooltip(null);
        return;
      }

      const data = param.seriesData.get(series) as
        | CandlestickData<Time>
        | undefined;

      if (!data) {
        setTooltip(null);
        return;
      }

      setTooltip({
        x: param.point.x,
        y: param.point.y,
        open: data.open,
        high: data.high,
        low: data.low,
        close: data.close,
        time: String(data.time),
      });
    };

    chart.subscribeCrosshairMove(handler);

    return () => {
      chart.unsubscribeCrosshairMove(handler);
      chart.removeSeries(series);
    };
  }, [getChart, candles]);

  const changePercent = tooltip
    ? (((tooltip.close - tooltip.open) / tooltip.open) * 100).toFixed(2)
    : null;

  return (
    <div ref={containerRef} style={{ width: "100%", height: 500, position: "relative" }}>
      {tooltip && (
        <div
          style={{
            position: "absolute",
            top: 12,
            left: 12,
            zIndex: 10,
            pointerEvents: "none",
          }}
          className="rounded bg-gray-900/90 px-3 py-2 text-sm text-gray-100 backdrop-blur"
        >
          <div className="mb-1 font-medium text-gray-400">{tooltip.time}</div>
          <div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
            <span className="text-gray-400">O</span>
            <span>{tooltip.open.toFixed(2)}</span>
            <span className="text-gray-400">H</span>
            <span>{tooltip.high.toFixed(2)}</span>
            <span className="text-gray-400">L</span>
            <span>{tooltip.low.toFixed(2)}</span>
            <span className="text-gray-400">C</span>
            <span>{tooltip.close.toFixed(2)}</span>
          </div>
          <div
            className={`mt-1 text-xs font-medium ${
              Number(changePercent) >= 0 ? "text-green-400" : "text-red-400"
            }`}
          >
            {Number(changePercent) >= 0 ? "+" : ""}
            {changePercent}%
          </div>
        </div>
      )}
    </div>
  );
}

The tooltip is positioned absolutely within the chart container and rendered by React. The crosshair callback fires on every mouse move, but the setTooltip call is lightweight — it's updating a small object, and only the tooltip <div> re-renders. The canvas is unaffected.

This is the right boundary between imperative and declarative. The chart handles the heavy rendering. React handles the DOM overlay. Neither steps on the other.


Pairing with TanStack Query for data fetching

In production, chart data comes from an API. TanStack Query handles the fetching, caching, and background refetching. The chart hook consumes the data when it's ready.

import { useQuery } from "@tanstack/react-query";
import { type ISeriesApi, type CandlestickData, type Time } from "lightweight-charts";
import { useEffect, useRef } from "react";

// assumes useChart() from the "core hook" section above

interface OHLCVResponse {
  time: string;
  open: number;
  high: number;
  low: number;
  close: number;
  volume: number;
}

const chartKeys = {
  all: ["charts"] as const,
  ohlcv: (symbol: string, interval: string) =>
    [...chartKeys.all, "ohlcv", symbol, interval] as const,
};

async function fetchOHLCV(
  symbol: string,
  interval: string
): Promise<OHLCVResponse[]> {
  const res = await fetch(
    `/api/ohlcv?symbol=${symbol}&interval=${interval}`
  );
  if (!res.ok) throw new Error("Failed to fetch OHLCV data");
  return res.json();
}

function useOHLCV(symbol: string, interval: string) {
  return useQuery({
    queryKey: chartKeys.ohlcv(symbol, interval),
    queryFn: () => fetchOHLCV(symbol, interval),
    staleTime: 60_000,
    select: (data) => ({
      candles: data.map(
        (d): CandlestickData<Time> => ({
          time: d.time as Time,
          open: d.open,
          high: d.high,
          low: d.low,
          close: d.close,
        })
      ),
      volumes: data.map((d) => d.volume),
    }),
  });
}

function TradingChart({
  symbol,
  interval,
}: {
  symbol: string;
  interval: string;
}) {
  const { containerRef, getChart } = useChart();
  const { data, isLoading, error } = useOHLCV(symbol, interval);
  const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);

  useEffect(() => {
    const chart = getChart();
    if (!chart || !data) return;

    // Clean up previous series if symbol/interval changed
    if (seriesRef.current) {
      chart.removeSeries(seriesRef.current);
    }

    const series = chart.addCandlestickSeries({
      upColor: "#22c55e",
      downColor: "#ef4444",
      borderDownColor: "#ef4444",
      borderUpColor: "#22c55e",
      wickDownColor: "#ef4444",
      wickUpColor: "#22c55e",
    });

    series.setData(data.candles);
    seriesRef.current = series;
    chart.timeScale().fitContent();

    return () => {
      chart.removeSeries(series);
      seriesRef.current = null;
    };
  }, [getChart, data]);

  return (
    <div ref={containerRef} style={{ width: "100%", height: 500, position: "relative" }}>
      {isLoading && (
        <div className="absolute inset-0 flex items-center justify-center bg-gray-950">
          <p className="text-gray-500">Loading chart data…</p>
        </div>
      )}
      {error && (
        <div className="absolute inset-0 flex items-center justify-center bg-gray-950">
          <p className="text-red-500">Failed to load chart: {error.message}</p>
        </div>
      )}
    </div>
  );
}

The select transform in useQuery converts the raw API response into the shape Lightweight Charts expects. This runs inside TanStack Query's memoization — the transformed data is referentially stable unless the raw data changes, which means the useEffect that sets chart data won't re-run on background refetches that return the same data.

When symbol or interval changes, TanStack Query fetches new data, the data reference changes, the effect re-runs, and the chart updates. One reactive pipeline, no manual orchestration.


Handling theme changes

If your app supports dark/light mode, the chart needs to respond. Since applyOptions is a merge operation (not a replace), you can update just the visual properties:

import { useEffect } from "react";
import { type IChartApi } from "lightweight-charts";

const themes = {
  dark: {
    layout: { background: { color: "transparent" }, textColor: "#9ca3af" },
    grid: {
      vertLines: { color: "#1f2937" },
      horzLines: { color: "#1f2937" },
    },
    rightPriceScale: { borderColor: "#374151" },
    timeScale: { borderColor: "#374151" },
  },
  light: {
    layout: { background: { color: "transparent" }, textColor: "#374151" },
    grid: {
      vertLines: { color: "#e5e7eb" },
      horzLines: { color: "#e5e7eb" },
    },
    rightPriceScale: { borderColor: "#d1d5db" },
    timeScale: { borderColor: "#d1d5db" },
  },
} as const;

function useChartTheme(
  getChart: () => IChartApi | null,
  theme: "dark" | "light"
) {
  useEffect(() => {
    const chart = getChart();
    if (!chart) return;

    chart.applyOptions(themes[theme]);
  }, [getChart, theme]);
}

Call useChartTheme(getChart, theme) in any chart component. The canvas repaints instantly — no unmount/remount, no data reload. This is applyOptions doing exactly what it was designed to do: surgical updates to a live chart.


Performance characteristics

To put numbers on it, here's what I measured on a mid-range laptop (M1 MacBook Air) rendering 5,000 candlesticks with a volume overlay and two SMA lines:

MetricRecharts (SVG)Chart.js (Canvas)Lightweight Charts
Initial render~620ms~180ms~45ms
Re-render on data change~400ms~160ms~12ms
Memory (heap)~38MB~18MB~9MB
Bundle size (gzipped)~52KB~68KB~45KB

The gap widens dramatically with real-time updates. At 4 updates/second, Recharts drops frames visibly. Chart.js holds up but requires manual canvas management to avoid full redraws. Lightweight Charts handles it without any special consideration — series.update() is designed for exactly this use case.


The abstraction boundary

The most important architectural decision is where to draw the line between React and the imperative API. After building several chart-heavy apps, here's the boundary I've settled on:

React owns:

  • Component lifecycle (mount/unmount the chart)
  • Data fetching (TanStack Query)
  • UI overlays (tooltips, legends, controls)
  • Theme and layout

Lightweight Charts owns:

  • Canvas rendering
  • User interaction (pan, zoom, crosshair)
  • Series data and updates
  • Animation frames

The bridge between them:

  • useRef for the chart and series instances
  • useEffect for wiring data into the chart
  • Event subscriptions (subscribeCrosshairMove) piped into useState for overlays
  • applyOptions for theme/config changes

Don't try to make Lightweight Charts "more React-like" by wrapping every series type in a JSX component with prop-driven updates. I've seen wrapper libraries that do this, and they re-introduce the exact performance problems you're trying to avoid. The ref-based imperative bridge is the right abstraction. It's explicit, it's fast, and it doesn't fight either paradigm.


Closing thoughts

Lightweight Charts is not the right tool for a marketing dashboard with three bar charts and a pie graph. Recharts will do that faster with less code. But for any application where chart performance is a feature — trading platforms, monitoring dashboards, analytics tools with large datasets — the imperative canvas approach is categorically better.

The complexity isn't in the library. It's in the paradigm shift. Once you internalize the ref-based bridge pattern, every imperative rendering library (Lightweight Charts, Three.js, PixiJS, deck.gl) follows the same integration model. React manages the lifecycle. The library manages the pixels. And they communicate through refs and effects, not props and re-renders.

That's not a workaround. That's the architecture.