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 }} />;
}| Mistake | Symptom | Fix |
|---|---|---|
init inside the render body | A new chart every render, canvas proliferation | Initialise in useEffect with an empty dependency array |
| No cleanup | Leaked canvas and listeners after unmount | Dispose in the effect's cleanup function |
No ResizeObserver | Chart does not follow layout changes | Observe the container and call resize() |
| A new option object every render | Full re-render and lost interaction state | Memoise the option, or depend on the data only |
| Passing a state proxy as the option | Strange render artefacts | Pass plain values; Zustand and Vue proxies both need unwrapping |
| StrictMode double mount | Two charts on one element | Always 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>shallowRefis 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
setOptioncalls in one tick. If the option is large, watch a version counter instead. - The container must have an explicit height before
onMountedruns, or the chart initialises at zero and needs a manualresize(). - Do not use
v-ifaround the container to hide the chart: it destroys the element. Use CSS visibility, or accept the re-init and handle it inmounted.
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;
});
}
}| Framework | Init hook | Instance storage | Watch mechanism |
|---|---|---|---|
| React | useEffect with no deps | useRef | useEffect on the option |
| Vue 3 | onMounted | shallowRef | watch or computed |
| Angular | afterNextRender | A private field | effect on a signal input |
| Svelte | onMount | A local variable | $effect or afterUpdate |
| Plain DOM | Script at the end of body | A closure variable | Call 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.Related
Getting started: install, init and lifecycle Interaction, resize and data updates
Last refreshed 2026-09-18.