Chart.js in React, Vue and Angular
Use the official wrappers and the plain canvas, handle StrictMode double-mounting, reach the chart instance, and update datasets without re-creating the chart.
React
import { useEffect, useRef } from 'react';
import { Chart } from '../charts/register';
export function RevenueChart({ labels, values, height = 360 }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
// Create the instance here, never during render.
chartRef.current = new Chart(canvasRef.current, {
type: 'bar',
data: { labels, datasets: [{ label: 'Revenue', data: values }] },
options: { responsive: true, maintainAspectRatio: false }
});
// StrictMode runs this effect twice in development. Destroying here is what
// makes the second mount clean rather than a duplicate.
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, []);
// Update in a separate effect so the instance survives data changes.
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.data.labels = labels;
chart.data.datasets[0].data = values;
chart.update();
}, [labels, values]);
return (
<div style={{ position: 'relative', height }}>
<canvas ref={canvasRef} role="img" aria-label="Revenue by quarter" />
</div>
);
}// The official wrapper handles the lifecycle for you.
import { Bar } from 'react-chartjs-2';
import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
export function Wrapped({ data }) {
const ref = useRef(null);
return (
<Bar
ref={ref}
data={data}
options={{ responsive: true, maintainAspectRatio: false, animation: false }}
// Reaching the instance: ref.current is the Chart.js chart
onReady={() => console.log(ref.current?.width)}
/>
);
}| Pitfall | Symptom | Fix |
|---|---|---|
| Creating the chart during render | Several instances, fast double animation | Create in an effect |
| No cleanup | Leaks on unmount, doubled tooltips | destroy() in the effect's return |
| Re-creating on data change | Animation restarts, hover state lost | Update in a second effect |
| Passing a memoised array that is recreated | An update on every render | Memoise the data or depend on primitives |
| Wrapper plus manual registration | Missing controllers at runtime | Register once in a single module |
| The canvas a flex child | Height collapses to zero | Wrap in a sized container |
💡
The wrapper packages are convenient but add a dependency and a second lifecycle model. If you need custom plugins, scriptable options with closures, or direct access to the canvas, the plain canvas with a
useEffect is easier to reason about — and it is the same code you would write for any other imperative library.Vue 3
<script setup>
import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue';
import { Chart } from '../charts/register';
const props = defineProps({
labels: { type: Array, required: true },
values: { type: Array, required: true },
height: { type: String, default: '360px' }
});
const canvas = ref(null);
// shallowRef: a chart instance must not be wrapped in a deep reactive proxy.
const chart = shallowRef(null);
onMounted(() => {
chart.value = new Chart(canvas.value, {
type: 'line',
data: { labels: props.labels, datasets: [{ label: 'Revenue', data: props.values }] },
options: { responsive: true, maintainAspectRatio: false }
});
});
// Watch the data, not the chart, and mutate in place.
watch(() => [props.labels, props.values], ([labels, values]) => {
const instance = chart.value;
if (!instance) return;
instance.data.labels = labels;
instance.data.datasets[0].data = values;
instance.update();
}, { deep: false });
onBeforeUnmount(() => {
chart.value?.destroy();
chart.value = null;
});
</script>
<template>
<div :style="{ position: 'relative', height }">
<canvas ref="canvas" role="img" aria-label="Revenue by quarter" />
</div>
</template>shallowRefis required. A deep reactive wrapper on a Chart.js instance breaks the library's internal comparisons and costs a lot of performance.- Watch the props rather than the chart, and keep the dependency list primitive. A deep watch on a large data array fires many times for one logical change.
- Assigning
instance.data.labelsand.datarather than replacinginstance.datakeeps the dataset objects, which keeps the animation and hover state. - The canvas needs a sized wrapper exactly as in plain HTML; Vue's reactivity does not change that requirement.
Angular
import {
Component, ElementRef, viewChild, input, effect,
afterNextRender, inject, DestroyRef
} from '@angular/core';
import { Chart, ChartConfiguration } from '../charts/register';
@Component({
selector: 'app-revenue-chart',
template: `
<div class="chart-frame">
<canvas #canvas role="img" aria-label="Revenue by quarter"></canvas>
</div>
`,
styles: `.chart-frame { position: relative; width: 100%; height: 360px; }`
})
export class RevenueChartComponent {
readonly configuration = input.required<ChartConfiguration>();
private readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly destroyRef = inject(DestroyRef);
private chart: Chart | null = null;
constructor() {
// afterNextRender: the element exists, and this never runs during SSR.
afterNextRender(() => {
this.chart = new Chart(this.canvas().nativeElement, this.configuration());
});
// React to input changes without recreating the chart.
effect(() => {
const config = this.configuration();
const chart = this.chart;
if (!chart) return;
chart.data = config.data;
if (config.options) chart.options = config.options;
chart.update();
});
// Teardown tied to the component's lifetime.
this.destroyRef.onDestroy(() => {
this.chart?.destroy();
this.chart = null;
});
}
}| Framework | Create in | Store the instance in | Update when |
|---|---|---|---|
| React | useEffect, empty deps | useRef | A second effect on the data |
| Vue 3 | onMounted | shallowRef | watch on the props |
| Angular | afterNextRender | A private field | An effect on the signal input |
| Svelte | onMount | A local variable | $effect or an explicit call |
| Web components | connectedCallback | A private field | A setter on the data property |
// Server-side rendering: Chart.js needs a canvas with a 2D context.
// Two workable approaches.
//
// 1. Render only in the browser. In Angular, afterNextRender already does this.
// In React, gate on a mounted flag so the server output matches the first
// client render exactly.
//
// 2. Use the node-canvas backend on the server to produce a static image.
// More work, and the chart is not interactive until hydration.
//
// The mounted-flag pattern for React:
import { useEffect, useState } from 'react';
export function ClientOnlyChart({ data, height = 360 }) {
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
return ready
? <RevenueChart data={data} height={height} />
: <div style={{ height }} aria-hidden="true" />;
}FAQ
Why does my chart appear twice in development?
React StrictMode mounts effects twice. If the chart is created in an effect and destroyed in its cleanup, the second mount replaces the first cleanly. If it is created during render or in a ref initialiser, the first instance is never destroyed and you see two.
Do I need the wrapper package?
Only for convenience. The wrapper manages creation and destruction for you, but it also fixes how props map to the option. When you need scriptable options with closures or direct canvas access, the plain canvas with a lifecycle hook is clearer.
Related
Installing Chart.js and tree-shaking the bundle Dynamic data, streaming and performance
Last refreshed 2026-09-18.