Search code examples
reactjsapexchartsreact-apexcharts

Y-axis scale in react-apexcharts doesn't update the symbol dynamically


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/[email protected]/prop-types.js"></script>
<script crossorigin src="https://unpkg.com/[email protected]/dist/apexcharts.js"></script>
<script crossorigin src="https://unpkg.com/[email protected]/dist/react-apexcharts.iife.min.js"></script>


Solution

  • Problem

    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())
        }
      }
    }
    

    reference

    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));

    Solution

    An ok solution

    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/[email protected]/prop-types.js"></script>
    <script crossorigin src="https://unpkg.com/[email protected]/dist/apexcharts.js"></script>
    <script crossorigin src="https://unpkg.com/[email protected]/dist/react-apexcharts.iife.min.js"></script>

    A better solution

    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:

    1. Adding scale as a key to the chartOptions object (in a similar fashion to the ok solution).
    2. Passing 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/[email protected]/prop-types.js"></script>
    <script crossorigin src="https://unpkg.com/[email protected]/dist/apexcharts.js"></script>
    <script crossorigin src="https://unpkg.com/[email protected]/dist/react-apexcharts.iife.min.js"></script>