I'm using react-apexcharts
to visualize some financial data. My chart has an option to switch the y-axis scale between percentages and dollars. Although the scale changes correctly when I click on the respective buttons, the axis labels (symbols % and $) don't update dynamically. They only change when I do a fast refresh. Below is a demo:
const { createRoot } = ReactDOM;
const { Fragment, StrictMode, useEffect, useState } = React;
const Chart = ReactApexChart;
const App = () => {
const baseChartOptions = {
chart: {
type: 'line',
},
series: [
{
name: 'serie1',
data: [1, 2, 3],
},
],
xaxis: {
categories: ['a', 'b', 'c'],
},
};
const [chartOptions, setChartOptions] = useState(baseChartOptions);
const [scale, setScale] = useState('percentage');
useEffect(() => {
const getYAxisFormatter = (val) => {
return scale === 'percentage' ? `${val.toFixed(1)}%` : `$${val.toFixed(2)}`;
};
setChartOptions((prevOptions) => ({
...prevOptions,
yaxis: { labels: { formatter: getYAxisFormatter } },
}));
}, [scale]);
return (
<Fragment>
<h4>{scale}</h4>
<button onClick={() => setScale('dollars')}>Dollars</button>
<button onClick={() => setScale('percentage')}>Percentage</button>
<Chart options={chartOptions} series={chartOptions.series} type="bar" width="500" height="320" />
</Fragment>
);
};
const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><App /></StrictMode>);
body {
font-family: sans-serif;
}
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/prop-types@15.8.1/prop-types.js"></script>
<script crossorigin src="https://unpkg.com/apexcharts@3.42.0/dist/apexcharts.js"></script>
<script crossorigin src="https://unpkg.com/react-apexcharts@1.4.1/dist/react-apexcharts.iife.min.js"></script>
The problem starts with how react-apexcharts
reacts to changes in the options. Below is a snippet of the code that runs when the component props update:
componentDidUpdate (prevProps) {
if (!this.chart) return null
const { options, series, height, width } = this.props
const prevOptions = JSON.stringify(prevProps.options)
const prevSeries = JSON.stringify(prevProps.series)
const currentOptions = JSON.stringify(options)
const currentSeries = JSON.stringify(series)
if (
prevOptions !== currentOptions ||
prevSeries !== currentSeries ||
height !== prevProps.height ||
width !== prevProps.width
) {
if (prevSeries === currentSeries) {
// series has not changed, but options or size have changed
this.chart.updateOptions(this.getConfig())
} else if (
prevOptions === currentOptions &&
height === prevProps.height &&
width === prevProps.width
) {
// options or size have not changed, just the series has changed
this.chart.updateSeries(series)
} else {
// both might be changed
this.chart.updateOptions(this.getConfig())
}
}
}
From the snippet above, it can be seen that it converts the previous and current options to a JSON string to do a comparison. When the scale changes, there's a reference to a new formatter function. However, functions get converted to empty objects in JSON.stringify
, so the resulting JSON strings are the same when compared. Here's a demo illustrating this:
function formatter1() {}
function formatter2() {}
const prevOptions = {
chart: {
type: 'line',
},
series: [
{
name: 'serie1',
data: [1, 2, 3],
},
],
xaxis: {
categories: ['a', 'b', 'c'],
},
yaxis: {
labels: {
formatter: formatter1,
}
}
};
const currentOptions = {
chart: {
type: 'line',
},
series: [
{
name: 'serie1',
data: [1, 2, 3],
},
],
xaxis: {
categories: ['a', 'b', 'c'],
},
yaxis: {
labels: {
formatter: formatter2,
}
}
};
// Logs `true` even though the options have different formatters
console.log(JSON.stringify(prevOptions) === JSON.stringify(currentOptions));
With the knowledge of how react-apexcharts
diffs options, you could add a key to the object that changes when the scale changes to hint to the diffing algorithm that an update needs to occur. In the demo below, scale
has been added to the options
object (see the call to setChartOptions
):
const { createRoot } = ReactDOM;
const { Fragment, StrictMode, useEffect, useState } = React;
const Chart = ReactApexChart;
const App = () => {
const baseChartOptions = {
chart: {
type: 'line',
},
series: [
{
name: 'serie1',
data: [1, 2, 3],
},
],
xaxis: {
categories: ['a', 'b', 'c'],
},
};
const [chartOptions, setChartOptions] = useState(baseChartOptions);
const [scale, setScale] = useState('percentage');
useEffect(() => {
const getYAxisFormatter = (val) => {
return scale === 'percentage' ? `${val.toFixed(1)}%` : `$${val.toFixed(2)}`;
};
setChartOptions((prevOptions) => ({
...prevOptions,
yaxis: { labels: { formatter: getYAxisFormatter } },
scale,
}));
}, [scale]);
return (
<Fragment>
<h4>{scale}</h4>
<button onClick={() => setScale('dollars')}>Dollars</button>
<button onClick={() => setScale('percentage')}>Percentage</button>
<Chart options={chartOptions} series={chartOptions.series} type="bar" width="500" height="320" />
</Fragment>
);
};
const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><App /></StrictMode>);
body {
font-family: sans-serif;
}
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/prop-types@15.8.1/prop-types.js"></script>
<script crossorigin src="https://unpkg.com/apexcharts@3.42.0/dist/apexcharts.js"></script>
<script crossorigin src="https://unpkg.com/react-apexcharts@1.4.1/dist/react-apexcharts.iife.min.js"></script>
Why does chartOptions
need to be stored in state? You can probably just have it as a plain old JavaScript object. This will simplify your code by getting rid of the code for setting up and updating the chart options state. From React's documentation:
Avoid redundant state
If you can calculate some information from the component’s props or its existing state variables during rendering, you should not put that information into that component’s state.
If you are worried about performance, useCallback
around getYAxisFormatter
and useMemo
around chartOptions
may be useful. However, as with all performance optimisations, benchmarking first is recommended.
With the above, providing a hint to the diffing algorithm that the component needs re-rendering is still required. With the refactor of removing redundant state, this could be done in two ways:
scale
as a key to the chartOptions
object (in a similar fashion to the ok solution).scale
to a key
prop on the Chart
component. When React diffs the DOM, a change in the key
hints to React that the component requires re-rendering. This option may be better than the option above because it's an explicit API – if react-apexcharts
changed its implementation details the previous option may not work. Below is a demo:const { createRoot } = ReactDOM;
const { Fragment, StrictMode, useEffect, useState } = React;
const Chart = ReactApexChart;
const App = () => {
const [scale, setScale] = useState('percentage');
const getYAxisFormatter = (val) => {
return scale === 'percentage' ? `${val.toFixed(1)}%` : `$${val.toFixed(2)}`;
};
const chartOptions = {
chart: {
type: 'line',
},
series: [
{
name: 'serie1',
data: [1, 2, 3],
},
],
xaxis: {
categories: ['a', 'b', 'c'],
},
yaxis: {
labels: {
formatter: getYAxisFormatter
}
}
};
return (
<Fragment>
<h4>{scale}</h4>
<button onClick={() => setScale('dollars')}>Dollars</button>
<button onClick={() => setScale('percentage')}>Percentage</button>
<Chart key={scale} options={chartOptions} series={chartOptions.series} type="bar" width="500" height="320" />
</Fragment>
);
};
const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><App /></StrictMode>);
body {
font-family: sans-serif;
}
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/prop-types@15.8.1/prop-types.js"></script>
<script crossorigin src="https://unpkg.com/apexcharts@3.42.0/dist/apexcharts.js"></script>
<script crossorigin src="https://unpkg.com/react-apexcharts@1.4.1/dist/react-apexcharts.iife.min.js"></script>