ECharts in React, Vue and Angular

Wrap the imperative chart in a component lifecycle, keep reactive proxies out of the option, resize correctly, and handle StrictMode double-mounting.

React

import { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

export function Chart({ option, height = 360, onReady }) {
  const containerRef = useRef(null);
  const chartRef = useRef(null);

  // 1. Create once. The empty dependency array is essential: re-running this
  //    would dispose and recreate the chart on every render.
  useEffect(() => {
    const chart = echarts.init(containerRef.current, null, { renderer: 'canvas' });
    chartRef.current = chart;

    const observer = new ResizeObserver(() => chart.resize());
    observer.observe(containerRef.current);

    onReady?.(chart);

    // 2. StrictMode in development mounts, unmounts and remounts. Disposing
    //    here is what prevents the "instance already exists" warning.
    return () => {
      observer.disconnect();
      chart.dispose();
      chartRef.current = null;
    };
  }, []);

  // 3. Apply option changes separately, and never re-initialise.
  useEffect(() => {
    chartRef.current?.setOption(option, { notMerge: false });
  }, [option]);

  return <div ref={containerRef} style={{ width: '100%', height }} />;
}
MistakeSymptomFix
init inside the render bodyA new chart every render, canvas proliferationInitialise in useEffect with an empty dependency array
No cleanupLeaked canvas and listeners after unmountDispose in the effect's cleanup function
No ResizeObserverChart does not follow layout changesObserve the container and call resize()
A new option object every renderFull re-render and lost interaction stateMemoise the option, or depend on the data only
Passing a state proxy as the optionStrange render artefactsPass plain values; Zustand and Vue proxies both need unwrapping
StrictMode double mountTwo charts on one elementAlways dispose in cleanup; create lazily inside the effect
💡
In development React invokes effects twice on purpose. If the chart is created in an effect and disposed in its cleanup, the second mount gets a clean element and everything works. If it is created at module scope or in a ref initialiser, the second mount finds an occupied element.

Vue 3

<script setup>
import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue';
import * as echarts from 'echarts/core';

const props = defineProps({
  option: { type: Object, required: true },
  height: { type: String, default: '360px' }
});

const container = ref(null);
// shallowRef: the chart instance must NOT be made deeply reactive. Wrapping a
// chart in a reactive proxy breaks its internal identity comparisons.
const chart = shallowRef(null);
let observer = null;

onMounted(() => {
  chart.value = echarts.init(container.value);
  chart.value.setOption(props.option);
  observer = new ResizeObserver(() => chart.value?.resize());
  observer.observe(container.value);
});

// Deep watch on the option: the parent may mutate a nested array.
watch(() => props.option, (next) => {
  chart.value?.setOption(next, { notMerge: false });
}, { deep: true });

onBeforeUnmount(() => {
  observer?.disconnect();
  chart.value?.dispose();
  chart.value = null;
});
</script>

<template>
  <div ref="container" :style="{ width: '100%', height }" />
</template>
  • shallowRef is not optional for a chart instance. A deep reactive wrapper on ECharts internals causes subtle breakage and a large performance cost.
  • A deep watch fires on every nested mutation, which can mean several setOption calls in one tick. If the option is large, watch a version counter instead.
  • The container must have an explicit height before onMounted runs, or the chart initialises at zero and needs a manual resize().
  • Do not use v-if around the container to hide the chart: it destroys the element. Use CSS visibility, or accept the re-init and handle it in mounted.

Angular

import {
  Component, ElementRef, viewChild, input, effect,
  afterNextRender, inject, DestroyRef, OnDestroy
} from '@angular/core';
import { getInstanceByDom, init, EChartsType, EChartsOption } from 'echarts/core';

@Component({
  selector: 'app-chart',
  template: `<div #container class="chart-container"></div>`,
  styles: `.chart-container { width: 100%; height: 360px; }`
})
export class ChartComponent {
  readonly option = input.required<EChartsOption>();

  private readonly container = viewChild.required<ElementRef<HTMLDivElement>>('container');
  private readonly destroyRef = inject(DestroyRef);
  private chart: EChartsType | null = null;
  private observer: ResizeObserver | null = null;

  constructor() {
    // afterNextRender: the element exists and this never runs during SSR.
    afterNextRender(() => {
      const el = this.container().nativeElement;

      // Reuse an existing instance if something initialised this element before.
      this.chart = getInstanceByDom(el) ?? init(el);

      this.observer = new ResizeObserver(() => this.chart?.resize());
      this.observer.observe(el);

      this.chart.setOption(this.option(), { notMerge: false });
    });

    // The effect runs when the input signal changes.
    effect(() => {
      const option = this.option();
      this.chart?.setOption(option, { notMerge: false });
    });

    this.destroyRef.onDestroy(() => {
      this.observer?.disconnect();
      this.chart?.dispose();
      this.chart = null;
    });
  }
}
FrameworkInit hookInstance storageWatch mechanism
ReactuseEffect with no depsuseRefuseEffect on the option
Vue 3onMountedshallowRefwatch or computed
AngularafterNextRenderA private fieldeffect on a signal input
SvelteonMountA local variable$effect or afterUpdate
Plain DOMScript at the end of bodyA closure variableCall setOption yourself
// Server rendering: ECharts needs a DOM. Two workable approaches.
//
// 1. Skip the chart on the server and render it after hydration.
//    In Angular: afterNextRender, as above. In React: guard with a mounted flag.
//
// 2. Render the chart on the server and ship the SVG output as markup.
//    The chart is static until the client hydrates, but the layout is correct
//    on first paint and the data is crawlable.

// React: a mounted flag keeps the server output and the first client render identical.
import { useState, useEffect } from 'react';

export function ClientOnlyChart(props) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  // The server and the first client render agree on the placeholder, which is
  // what prevents a hydration mismatch.
  return mounted ? <Chart {...props} /> : <div style={{ height: props.height ?? 360 }} aria-hidden="true" />;
}

In every framework the same three rules apply: create the chart after the DOM exists, store the instance outside the reactive system, and dispose it in the framework's teardown. Everything else is a variation on those three.

FAQ

Why do I get two charts in React StrictMode?
The effect ran twice without disposing the first instance. Dispose in the cleanup function returned by the effect; the second mount then gets a clean element. Creating the chart in a ref initialiser or at module scope is what causes the duplicate.
Should the option live in reactive state?
The data feeding it should; the chart instance must not. Keep the instance in a useRef, shallowRef or a plain field, and let the framework tell you when the option changed. Wrapping an ECharts instance in a deep proxy breaks it.

Getting started: install, init and lifecycle Interaction, resize and data updates

Last refreshed 2026-09-18.