You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/02/11 17:16:10 UTC

(superset) 01/01: chore(lint): convert class components to function components

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch chore/lint-cleanup-function-components
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 5da915a37346c2fe36a7b89d6abb776bec5f1c32
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Wed Feb 11 09:15:51 2026 -0800

    chore(lint): convert class components to function components
    
    Convert all remaining React class components to function components
    using hooks (useState, useCallback, useEffect, useRef, useMemo) to
    satisfy the react-prefer-function-component ESLint rule.
    
    Key changes:
    - Converted components in dashboard, explore, SqlLab, and Chart areas
    - Updated associated test files with proper typing
    - Fixed JSX.Element return types for components used as JSX
    - Added explicit ControlHeader props where needed
    - Fixed shouldFocus callback signature in WithPopoverMenu usage
    
    Notable exceptions (not converted):
    - ErrorBoundary (uses componentDidCatch)
    - DragDroppable (react-dnd requires class instances)
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 .../src/chart-composition/ChartFrame.tsx           |   63 +-
 .../src/chart-composition/legend/WithLegend.tsx    |  167 +-
 .../src/chart-composition/tooltip/TooltipFrame.tsx |   28 +-
 .../src/chart/components/ChartDataProvider.tsx     |  194 +-
 .../src/chart/components/SuperChart.tsx            |  425 +++--
 .../src/chart/components/SuperChartCore.tsx        |  376 ++--
 .../src/chart/components/reactify.tsx              |  109 +-
 .../test/chart/components/SuperChartCore.test.tsx  |   26 +-
 .../test/chart/components/reactify.test.tsx        |   84 +-
 .../storybook/shared/components/Expandable.tsx     |   53 +-
 .../storybook/shared/components/VerifyCORS.tsx     |  113 +-
 .../NumberFormat.stories.tsx                       |  198 +-
 .../superset-ui-time-format/TimeFormat.stories.tsx |  175 +-
 .../src/HorizonChart.tsx                           |  101 +-
 .../legacy-plugin-chart-horizon/src/HorizonRow.tsx |  268 ++-
 .../legacy-plugin-chart-map-box/src/MapBox.tsx     |  198 +-
 .../src/ScatterPlotGlowOverlay.tsx                 |  573 +++---
 .../src/PairedTTest.tsx                            |   71 +-
 .../src/TTestTable.tsx                             |  500 ++---
 .../src/react-pivottable/PivotTable.tsx            |   10 +-
 .../src/react-pivottable/TableRenderers.tsx        | 1914 ++++++++++----------
 .../src/chart/WordCloud.tsx                        |  262 +--
 .../src/SqlLab/components/App/App.test.tsx         |    4 +-
 .../src/SqlLab/components/App/index.tsx            |  196 +-
 .../SqlLab/components/TabbedSqlEditors/index.tsx   |  284 +--
 superset-frontend/src/components/Chart/Chart.tsx   |  480 ++---
 .../src/components/Chart/ChartRenderer.tsx         |  783 ++++----
 .../src/components/CopyToClipboard/index.tsx       |  141 +-
 .../CollectionTable/CollectionTable.test.tsx       |    2 +-
 .../components/CollectionTable/index.tsx           |  640 ++++---
 .../DatasourceEditor/DatasourceEditor.tsx          | 1817 +++++++++----------
 .../tests/DatasourceEditor.test.tsx                |    3 +-
 .../tests/DatasourceEditorCurrency.test.tsx        |   14 +-
 .../src/components/ErrorBoundary/index.tsx         |    1 +
 .../src/dashboard/components/Dashboard.tsx         |  356 ++--
 .../dashboard/components/DashboardBuilder/utils.ts |    7 +-
 .../src/dashboard/components/DashboardGrid.tsx     |  468 +++--
 .../dashboard/components/PublishedStatus/index.tsx |   96 +-
 .../src/dashboard/components/SliceAdder.tsx        |  536 +++---
 .../components/UndoRedoKeyListeners/index.tsx      |   69 +-
 .../src/dashboard/components/dnd/DragDroppable.tsx |   20 +-
 .../components/filterscope/FilterScopeModal.tsx    |   62 +-
 .../components/filterscope/FilterScopeSelector.tsx |  822 ++++-----
 .../components/gridComponents/Chart/Chart.tsx      |    4 +-
 .../components/gridComponents/Divider/Divider.tsx  |   81 +-
 .../components/gridComponents/Header/Header.tsx    |  262 ++-
 .../gridComponents/Markdown/Markdown.tsx           |  517 +++---
 .../gridComponents/new/DraggableNewComponent.tsx   |   68 +-
 .../components/menu/BackgroundStyleDropdown.tsx    |   30 +-
 .../src/dashboard/components/menu/HoverMenu.tsx    |   71 +-
 .../components/menu/MarkdownModeDropdown.tsx       |   29 +-
 .../components/menu/WithPopoverMenu.test.tsx       |    8 +-
 .../dashboard/components/menu/WithPopoverMenu.tsx  |  252 ++-
 .../src/explore/components/SaveModal.tsx           | 1235 ++++++-------
 .../AnnotationLayerControl/AnnotationLayer.tsx     | 1265 +++++++------
 .../controls/AnnotationLayerControl/index.tsx      |  360 ++--
 .../components/controls/CheckboxControl.tsx        |   49 +-
 .../controls/CollectionControl/index.tsx           |  258 +--
 .../controls/DatasourceControl/index.tsx           |  739 ++++----
 .../FilterControl/AdhocFilterControl/index.tsx     |  441 +++--
 .../FilterControl/AdhocFilterEditPopover/index.tsx |  643 +++----
 .../AdhocFilterPopoverTrigger/index.tsx            |  138 +-
 .../controls/FixedOrMetricControl/index.tsx        |  274 ++-
 .../MetricControl/AdhocMetricEditPopover/index.tsx |  868 ++++-----
 .../controls/MetricControl/AdhocMetricOption.tsx   |  104 +-
 .../MetricControl/AdhocMetricPopoverTrigger.tsx    |  488 +++--
 .../explore/components/controls/SelectControl.tsx  |  329 ++--
 .../explore/components/controls/SpatialControl.tsx |  347 ++--
 .../components/controls/TextAreaControl.test.tsx   |    4 +-
 .../components/controls/TextAreaControl.tsx        |  390 ++--
 .../components/controls/TextControl/index.tsx      |  169 +-
 .../controls/TimeSeriesColumnControl/index.tsx     |  413 +++--
 .../components/controls/ViewportControl.tsx        |  102 +-
 superset-frontend/src/features/home/RightMenu.tsx  |   48 +-
 .../src/pages/ChartCreation/ChartCreation.test.tsx |   44 +-
 .../src/pages/ChartCreation/index.tsx              |  421 +++--
 76 files changed, 11649 insertions(+), 11511 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
index 88cf52ec72f..91836814e9f 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { PureComponent, ReactNode } from 'react';
+import { memo, ReactNode } from 'react';
 
 import { isDefined } from '../utils';
 
@@ -29,7 +29,7 @@ type Props = {
   contentWidth?: number;
   contentHeight?: number;
   height: number;
-  renderContent: ({
+  renderContent?: ({
     height,
     width,
   }: {
@@ -39,36 +39,35 @@ type Props = {
   width: number;
 };
 
-export default class ChartFrame extends PureComponent<Props, {}> {
-  static defaultProps = {
-    renderContent() {},
-  };
+function ChartFrame({
+  contentWidth,
+  contentHeight,
+  width,
+  height,
+  renderContent = () => null,
+}: Props) {
+  const overflowX = checkNumber(contentWidth) && contentWidth > width;
+  const overflowY = checkNumber(contentHeight) && contentHeight > height;
 
-  render() {
-    const { contentWidth, contentHeight, width, height, renderContent } =
-      this.props;
-
-    const overflowX = checkNumber(contentWidth) && contentWidth > width;
-    const overflowY = checkNumber(contentHeight) && contentHeight > height;
-
-    if (overflowX || overflowY) {
-      return (
-        <div
-          style={{
-            height,
-            overflowX: overflowX ? 'auto' : 'hidden',
-            overflowY: overflowY ? 'auto' : 'hidden',
-            width,
-          }}
-        >
-          {renderContent({
-            height: Math.max(contentHeight ?? 0, height),
-            width: Math.max(contentWidth ?? 0, width),
-          })}
-        </div>
-      );
-    }
-
-    return renderContent({ height, width });
+  if (overflowX || overflowY) {
+    return (
+      <div
+        style={{
+          height,
+          overflowX: overflowX ? 'auto' : 'hidden',
+          overflowY: overflowY ? 'auto' : 'hidden',
+          width,
+        }}
+      >
+        {renderContent({
+          height: Math.max(contentHeight ?? 0, height),
+          width: Math.max(contentWidth ?? 0, width),
+        })}
+      </div>
+    );
   }
+
+  return <>{renderContent({ height, width })}</>;
 }
+
+export default memo(ChartFrame);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
index a2cac92ee33..48e7309cdab 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
@@ -17,26 +17,19 @@
  * under the License.
  */
 
-import { CSSProperties, ReactNode, PureComponent } from 'react';
+import { CSSProperties, ReactNode, memo, useMemo } from 'react';
 import { ParentSize } from '@visx/responsive';
 
-const defaultProps = {
-  className: '',
-  height: 'auto' as number | string,
-  position: 'top',
-  width: 'auto' as number | string,
-};
-
 type Props = {
-  className: string;
+  className?: string;
   debounceTime?: number;
-  width: number | string;
-  height: number | string;
+  width?: number | string;
+  height?: number | string;
   legendJustifyContent?: 'center' | 'flex-start' | 'flex-end';
-  position: 'top' | 'left' | 'bottom' | 'right';
+  position?: 'top' | 'left' | 'bottom' | 'right';
   renderChart: (dim: { width: number; height: number }) => ReactNode;
   renderLegend?: (params: { direction: string }) => ReactNode;
-} & Readonly<typeof defaultProps>;
+};
 
 const LEGEND_STYLE_BASE: CSSProperties = {
   display: 'flex',
@@ -52,95 +45,101 @@ const CHART_STYLE_BASE: CSSProperties = {
   position: 'relative',
 };
 
-class WithLegend extends PureComponent<Props, {}> {
-  static defaultProps = defaultProps;
-
-  getContainerDirection(): CSSProperties['flexDirection'] {
-    const { position } = this.props;
-
-    if (position === 'left') {
-      return 'row';
-    }
-    if (position === 'right') {
-      return 'row-reverse';
-    }
-    if (position === 'bottom') {
-      return 'column-reverse';
-    }
-
-    return 'column';
+function getContainerDirection(
+  position: Props['position'],
+): CSSProperties['flexDirection'] {
+  if (position === 'left') {
+    return 'row';
+  }
+  if (position === 'right') {
+    return 'row-reverse';
+  }
+  if (position === 'bottom') {
+    return 'column-reverse';
   }
 
-  getLegendJustifyContent() {
-    const { legendJustifyContent, position } = this.props;
-    if (legendJustifyContent) {
-      return legendJustifyContent;
-    }
-
-    if (position === 'left' || position === 'right') {
-      return 'flex-start';
-    }
+  return 'column';
+}
 
-    return 'flex-end';
+function getLegendJustifyContent(
+  legendJustifyContent: Props['legendJustifyContent'],
+  position: Props['position'],
+) {
+  if (legendJustifyContent) {
+    return legendJustifyContent;
   }
 
-  render() {
-    const {
-      className,
-      debounceTime,
-      width,
-      height,
-      position,
-      renderChart,
-      renderLegend,
-    } = this.props;
+  if (position === 'left' || position === 'right') {
+    return 'flex-start';
+  }
 
-    const isHorizontal = position === 'left' || position === 'right';
+  return 'flex-end';
+}
 
-    const style: CSSProperties = {
+function WithLegend({
+  className = '',
+  debounceTime,
+  width = 'auto',
+  height = 'auto',
+  legendJustifyContent,
+  position = 'top',
+  renderChart,
+  renderLegend,
+}: Props) {
+  const isHorizontal = position === 'left' || position === 'right';
+
+  const style: CSSProperties = useMemo(
+    () => ({
       display: 'flex',
-      flexDirection: this.getContainerDirection(),
+      flexDirection: getContainerDirection(position),
       height,
       width,
-    };
+    }),
+    [position, height, width],
+  );
 
-    const chartStyle: CSSProperties = { ...CHART_STYLE_BASE };
+  const chartStyle: CSSProperties = useMemo(() => {
+    const baseStyle = { ...CHART_STYLE_BASE };
     if (isHorizontal) {
-      chartStyle.width = 0;
+      baseStyle.width = 0;
     } else {
-      chartStyle.height = 0;
+      baseStyle.height = 0;
     }
+    return baseStyle;
+  }, [isHorizontal]);
 
-    const legendDirection = isHorizontal ? 'column' : 'row';
-    const legendStyle: CSSProperties = {
+  const legendDirection = isHorizontal ? 'column' : 'row';
+  const legendStyle: CSSProperties = useMemo(
+    () => ({
       ...LEGEND_STYLE_BASE,
       flexDirection: legendDirection,
-      justifyContent: this.getLegendJustifyContent(),
-    };
-
-    return (
-      <div className={`with-legend ${className}`} style={style}>
-        {renderLegend && (
-          <div className="legend-container" style={legendStyle}>
-            {renderLegend({
-              // Pass flexDirection for @vx/legend to arrange legend items
-              direction: legendDirection,
-            })}
-          </div>
-        )}
-        <div className="main-container" style={chartStyle}>
-          <ParentSize debounceTime={debounceTime}>
-            {(parent: { width: number; height: number }) =>
-              parent.width > 0 && parent.height > 0
-                ? // Only render when necessary
-                  renderChart(parent)
-                : null
-            }
-          </ParentSize>
+      justifyContent: getLegendJustifyContent(legendJustifyContent, position),
+    }),
+    [legendDirection, legendJustifyContent, position],
+  );
+
+  return (
+    <div className={`with-legend ${className}`} style={style}>
+      {renderLegend && (
+        <div className="legend-container" style={legendStyle}>
+          {renderLegend({
+            // Pass flexDirection for @vx/legend to arrange legend items
+            direction: legendDirection,
+          })}
         </div>
+      )}
+      <div className="main-container" style={chartStyle}>
+        <ParentSize debounceTime={debounceTime}>
+          {(parent: { width: number; height: number }) =>
+            parent.width > 0 && parent.height > 0
+              ? // Only render when necessary
+                renderChart(parent)
+              : null
+          }
+        </ParentSize>
       </div>
-    );
-  }
+    </div>
+  );
 }
 
-export default WithLegend;
+export default memo(WithLegend);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
index d470a229403..19008654316 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
@@ -17,31 +17,21 @@
  * under the License.
  */
 
-import { PureComponent, ReactNode } from 'react';
-
-const defaultProps = {
-  className: '',
-};
+import { memo, ReactNode } from 'react';
 
 type Props = {
   className?: string;
   children: ReactNode;
-} & Readonly<typeof defaultProps>;
+};
 
 const CONTAINER_STYLE = { padding: 8 };
 
-class TooltipFrame extends PureComponent<Props, {}> {
-  static defaultProps = defaultProps;
-
-  render() {
-    const { className, children } = this.props;
-
-    return (
-      <div className={className} style={CONTAINER_STYLE}>
-        {children}
-      </div>
-    );
-  }
+function TooltipFrame({ className = '', children }: Props) {
+  return (
+    <div className={className} style={CONTAINER_STYLE}>
+      {children}
+    </div>
+  );
 }
 
-export default TooltipFrame;
+export default memo(TooltipFrame);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
index 457bdd9fefb..a7776fbf0dc 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
@@ -17,8 +17,7 @@
  * under the License.
  */
 
-/* eslint react/sort-comp: 'off' */
-import { PureComponent, ReactNode } from 'react';
+import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
 import {
   SupersetClientInterface,
   RequestConfig,
@@ -67,103 +66,102 @@ export type ChartDataProviderState = {
   error?: ProvidedProps['error'];
 };
 
-class ChartDataProvider extends PureComponent<
-  ChartDataProviderProps,
-  ChartDataProviderState
-> {
-  readonly chartClient: ChartClient;
-
-  constructor(props: ChartDataProviderProps) {
-    super(props);
-    this.state = { status: 'uninitialized' };
-    this.chartClient = new ChartClient({ client: props.client });
-  }
-
-  componentDidMount() {
-    this.handleFetchData();
-  }
-
-  componentDidUpdate(prevProps: ChartDataProviderProps) {
-    const { formData, sliceId } = this.props;
-    if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
-      this.handleFetchData();
-    }
-  }
-
-  private extractSliceIdAndFormData() {
-    const { formData, sliceId } = this.props;
-    return formData ? { formData } : { sliceId: sliceId as number };
-  }
-
-  private handleFetchData = () => {
-    const {
-      loadDatasource,
-      formDataRequestOptions,
-      datasourceRequestOptions,
-      queryRequestOptions,
-    } = this.props;
-
-    this.setState({ status: 'loading' }, () => {
-      try {
-        this.chartClient
-          .loadFormData(
-            this.extractSliceIdAndFormData(),
-            formDataRequestOptions,
-          )
-          .then(formData =>
-            Promise.all([
-              loadDatasource
-                ? this.chartClient.loadDatasource(
-                    formData.datasource,
-                    datasourceRequestOptions,
-                  )
-                : Promise.resolve(undefined),
-              this.chartClient.loadQueryData(formData, queryRequestOptions),
-            ]).then(
-              ([datasource, queriesData]) =>
-                // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
-                ({
-                  datasource,
-                  formData,
-                  queriesData,
-                }) as Payload,
-            ),
-          )
-          .then(this.handleReceiveData)
-          .catch(this.handleError);
-      } catch (error) {
-        this.handleError(error as Error);
-      }
-    });
-  };
-
-  private handleReceiveData = (payload?: Payload) => {
-    const { onLoaded } = this.props;
-    if (onLoaded) onLoaded(payload);
-    this.setState({ payload, status: 'loaded' });
-  };
-
-  private handleError = (error: ProvidedProps['error']) => {
-    const { onError } = this.props;
-    if (onError) onError(error);
-    this.setState({ error, status: 'error' });
-  };
-
-  render() {
-    const { children } = this.props;
-    const { status, payload, error } = this.state;
-
-    switch (status) {
-      case 'loading':
-        return children({ loading: true });
-      case 'loaded':
-        return children({ payload });
-      case 'error':
-        return children({ error });
-      case 'uninitialized':
-      default:
-        return null;
+function ChartDataProvider({
+  children,
+  client,
+  formData,
+  sliceId,
+  loadDatasource,
+  onError,
+  onLoaded,
+  formDataRequestOptions,
+  datasourceRequestOptions,
+  queryRequestOptions,
+}: ChartDataProviderProps) {
+  const [state, setState] = useState<ChartDataProviderState>({
+    status: 'uninitialized',
+  });
+
+  const chartClient = useMemo(() => new ChartClient({ client }), [client]);
+
+  const extractSliceIdAndFormData = useCallback(
+    (): SliceIdAndOrFormData =>
+      formData ? { formData } : { sliceId: sliceId as number },
+    [formData, sliceId],
+  );
+
+  const handleReceiveData = useCallback(
+    (payload?: Payload) => {
+      if (onLoaded) onLoaded(payload);
+      setState({ payload, status: 'loaded' });
+    },
+    [onLoaded],
+  );
+
+  const handleError = useCallback(
+    (error: ProvidedProps['error']) => {
+      if (onError) onError(error);
+      setState({ error, status: 'error' });
+    },
+    [onError],
+  );
+
+  const handleFetchData = useCallback(() => {
+    setState({ status: 'loading' });
+    try {
+      chartClient
+        .loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
+        .then(loadedFormData =>
+          Promise.all([
+            loadDatasource
+              ? chartClient.loadDatasource(
+                  loadedFormData.datasource,
+                  datasourceRequestOptions,
+                )
+              : Promise.resolve(undefined),
+            chartClient.loadQueryData(loadedFormData, queryRequestOptions),
+          ]).then(
+            ([datasource, queriesData]) =>
+              ({
+                datasource,
+                formData: loadedFormData,
+                queriesData,
+              }) as Payload,
+          ),
+        )
+        .then(handleReceiveData)
+        .catch(handleError);
+    } catch (error) {
+      handleError(error as Error);
     }
+  }, [
+    chartClient,
+    extractSliceIdAndFormData,
+    formDataRequestOptions,
+    loadDatasource,
+    datasourceRequestOptions,
+    queryRequestOptions,
+    handleReceiveData,
+    handleError,
+  ]);
+
+  // Fetch data on mount and when formData or sliceId changes
+  useEffect(() => {
+    handleFetchData();
+  }, [formData, sliceId, handleFetchData]);
+
+  const { status, payload, error } = state;
+
+  switch (status) {
+    case 'loading':
+      return children({ loading: true });
+    case 'loaded':
+      return children({ payload });
+    case 'error':
+      return children({ error });
+    case 'uninitialized':
+    default:
+      return null;
   }
 }
 
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
index 553a98f568a..3a91e58c8bb 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
@@ -21,8 +21,10 @@ import {
   ReactNode,
   RefObject,
   ComponentType,
-  PureComponent,
   Fragment,
+  useCallback,
+  useMemo,
+  useRef,
 } from 'react';
 
 import {
@@ -32,22 +34,19 @@ import {
 } from 'react-error-boundary';
 import { ParentSize } from '@visx/responsive';
 import { createSelector } from 'reselect';
-import { withTheme } from '@emotion/react';
+import { useTheme } from '@emotion/react';
 import { parseLength, Dimension } from '../../dimension';
 import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
-import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
+import SuperChartCore, {
+  Props as SuperChartCoreProps,
+  SuperChartCoreRef,
+} from './SuperChartCore';
 import DefaultFallbackComponent from './FallbackComponent';
 import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
 import NoResultsComponent from './NoResultsComponent';
 import { isMatrixifyEnabled } from '../types/matrixify';
 import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
-
-const defaultProps = {
-  FallbackComponent: DefaultFallbackComponent,
-  height: 400 as string | number,
-  width: '100%' as string | number,
-  enableNoResults: true,
-};
+import { SupersetTheme } from '@apache-superset/core/ui';
 
 export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
 
@@ -102,215 +101,261 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
     inContextMenu?: boolean;
   };
 
-type PropsWithDefault = Props & Readonly<typeof defaultProps>;
-
-class SuperChart extends PureComponent<Props, {}> {
+function SuperChart({
+  id,
+  className,
+  chartType,
+  preTransformProps,
+  overrideTransformProps,
+  postTransformProps,
+  onRenderSuccess,
+  onRenderFailure,
+  disableErrorBoundary,
+  FallbackComponent = DefaultFallbackComponent,
+  onErrorBoundary,
+  Wrapper,
+  queriesData,
+  enableNoResults = true,
+  noResults,
+  theme: themeProp,
+  debounceTime,
+  height = 400,
+  width = '100%',
+  ...rest
+}: Props): JSX.Element {
   /**
-   * SuperChart's core
+   * SuperChart's core ref
    */
-  core?: SuperChartCore | null;
+  const coreRef = useRef<SuperChartCoreRef | null>(null);
 
-  private createChartProps = ChartProps.createSelector();
+  // Use theme from hook, falling back to prop if provided
+  const themeFromContext = useTheme() as SupersetTheme;
+  const theme = themeProp ?? themeFromContext;
 
-  private parseDimension = createSelector(
-    [
-      ({ width }: { width: string | number; height: string | number }) => width,
-      ({ height }) => height,
-    ],
-    (width, height) => {
-      // Parse them in case they are % or 'auto'
-      const widthInfo = parseLength(width);
-      const heightInfo = parseLength(height);
-      const boxHeight = heightInfo.isDynamic
-        ? `${heightInfo.multiplier * 100}%`
-        : heightInfo.value;
-      const boxWidth = widthInfo.isDynamic
-        ? `${widthInfo.multiplier * 100}%`
-        : widthInfo.value;
-      const style = {
-        height: boxHeight,
-        width: boxWidth,
-      };
+  const createChartProps = useMemo(() => ChartProps.createSelector(), []);
 
-      // bounding box will ensure that when one dimension is not dynamic
-      // e.g. height = 300
-      // the auto size will be bound to that value instead of being 100% by default
-      // e.g. height: 300 instead of height: '100%'
-      const BoundingBox =
-        widthInfo.isDynamic &&
-        heightInfo.isDynamic &&
-        widthInfo.multiplier === 1 &&
-        heightInfo.multiplier === 1
-          ? Fragment
-          : ({ children }: { children: ReactNode }) => (
-              <div style={style}>{children}</div>
-            );
+  const parseDimension = useMemo(
+    () =>
+      createSelector(
+        [
+          ({ width: w }: { width: string | number; height: string | number }) =>
+            w,
+          ({
+            height: h,
+          }: {
+            width: string | number;
+            height: string | number;
+          }) => h,
+        ],
+        (w, h) => {
+          // Parse them in case they are % or 'auto'
+          const widthInfo = parseLength(w);
+          const heightInfo = parseLength(h);
+          const boxHeight = heightInfo.isDynamic
+            ? `${heightInfo.multiplier * 100}%`
+            : heightInfo.value;
+          const boxWidth = widthInfo.isDynamic
+            ? `${widthInfo.multiplier * 100}%`
+            : widthInfo.value;
+          const style = {
+            height: boxHeight,
+            width: boxWidth,
+          };
 
-      return { BoundingBox, heightInfo, widthInfo };
-    },
+          // bounding box will ensure that when one dimension is not dynamic
+          // e.g. height = 300
+          // the auto size will be bound to that value instead of being 100% by default
+          // e.g. height: 300 instead of height: '100%'
+          const BoundingBox =
+            widthInfo.isDynamic &&
+            heightInfo.isDynamic &&
+            widthInfo.multiplier === 1 &&
+            heightInfo.multiplier === 1
+              ? Fragment
+              : ({ children }: { children: ReactNode }) => (
+                  <div style={style}>{children}</div>
+                );
+
+          return { BoundingBox, heightInfo, widthInfo };
+        },
+      ),
+    [],
   );
 
-  static defaultProps = defaultProps;
+  const setRef = useCallback((core: SuperChartCoreRef | null) => {
+    coreRef.current = core;
+  }, []);
 
-  private setRef = (core: SuperChartCore | null) => {
-    this.core = core;
-  };
+  const getQueryCount = useCallback(
+    () => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
+    [chartType],
+  );
 
-  private getQueryCount = () =>
-    getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
+  const renderChart = useCallback(
+    (chartWidth: number, chartHeight: number) => {
+      const chartProps = createChartProps({
+        ...rest,
+        queriesData,
+        height: chartHeight,
+        width: chartWidth,
+        theme,
+      });
 
-  renderChart(width: number, height: number) {
-    const {
-      id,
-      className,
-      chartType,
-      preTransformProps,
-      overrideTransformProps,
-      postTransformProps,
-      onRenderSuccess,
-      onRenderFailure,
-      disableErrorBoundary,
-      FallbackComponent,
-      onErrorBoundary,
-      Wrapper,
-      queriesData,
-      enableNoResults,
-      noResults,
-      theme,
-      ...rest
-    } = this.props as PropsWithDefault;
+      // Check if Matrixify is enabled - use rawFormData (snake_case)
+      const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
 
-    const chartProps = this.createChartProps({
-      ...rest,
-      queriesData,
-      height,
-      width,
-      theme,
-    });
+      if (matrixifyEnabled) {
+        // When matrixify is enabled, queriesData is expected to be empty
+        // since each cell fetches its own data via StatefulChart
+        const matrixifyChart = (
+          <MatrixifyGridRenderer
+            formData={chartProps.rawFormData}
+            datasource={chartProps.datasource}
+            width={chartWidth}
+            height={chartHeight}
+            hooks={chartProps.hooks}
+          />
+        );
 
-    // Check if Matrixify is enabled - use rawFormData (snake_case)
-    const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
+        // Apply wrapper if provided
+        const wrappedChart = Wrapper ? (
+          <Wrapper width={chartWidth} height={chartHeight}>
+            {matrixifyChart}
+          </Wrapper>
+        ) : (
+          matrixifyChart
+        );
 
-    if (matrixifyEnabled) {
-      // When matrixify is enabled, queriesData is expected to be empty
-      // since each cell fetches its own data via StatefulChart
-      const matrixifyChart = (
-        <MatrixifyGridRenderer
-          formData={chartProps.rawFormData}
-          datasource={chartProps.datasource}
-          width={width}
-          height={height}
-          hooks={chartProps.hooks}
-        />
-      );
+        // Include error boundary unless disabled
+        return disableErrorBoundary === true ? (
+          wrappedChart
+        ) : (
+          <ErrorBoundary
+            FallbackComponent={props => (
+              <FallbackComponent
+                width={chartWidth}
+                height={chartHeight}
+                {...props}
+              />
+            )}
+            onError={onErrorBoundary}
+          >
+            {wrappedChart}
+          </ErrorBoundary>
+        );
+      }
 
-      // Apply wrapper if provided
-      const wrappedChart = Wrapper ? (
-        <Wrapper width={width} height={height}>
-          {matrixifyChart}
-        </Wrapper>
-      ) : (
-        matrixifyChart
-      );
+      // Check for no results only for non-matrixified charts
+      const noResultQueries =
+        enableNoResults &&
+        (!queriesData ||
+          queriesData
+            .slice(0, getQueryCount())
+            .every(
+              ({ data }) => !data || (Array.isArray(data) && data.length === 0),
+            ));
 
-      // Include error boundary unless disabled
+      let chart: JSX.Element;
+      if (noResultQueries) {
+        chart = noResults ? (
+          <>{noResults}</>
+        ) : (
+          <NoResultsComponent
+            id={id}
+            className={className}
+            height={chartHeight}
+            width={chartWidth}
+          />
+        );
+      } else {
+        const chartWithoutWrapper = (
+          <SuperChartCore
+            ref={setRef}
+            id={id}
+            className={className}
+            chartType={chartType}
+            chartProps={chartProps}
+            preTransformProps={preTransformProps}
+            overrideTransformProps={overrideTransformProps}
+            postTransformProps={postTransformProps}
+            onRenderSuccess={onRenderSuccess}
+            onRenderFailure={onRenderFailure}
+          />
+        );
+        chart = Wrapper ? (
+          <Wrapper width={chartWidth} height={chartHeight}>
+            {chartWithoutWrapper}
+          </Wrapper>
+        ) : (
+          chartWithoutWrapper
+        );
+      }
+      // Include the error boundary by default unless it is specifically disabled.
       return disableErrorBoundary === true ? (
-        wrappedChart
+        chart
       ) : (
         <ErrorBoundary
           FallbackComponent={props => (
-            <FallbackComponent width={width} height={height} {...props} />
+            <FallbackComponent
+              width={chartWidth}
+              height={chartHeight}
+              {...props}
+            />
           )}
           onError={onErrorBoundary}
         >
-          {wrappedChart}
+          {chart}
         </ErrorBoundary>
       );
-    }
+    },
+    [
+      createChartProps,
+      rest,
+      queriesData,
+      theme,
+      Wrapper,
+      disableErrorBoundary,
+      FallbackComponent,
+      onErrorBoundary,
+      enableNoResults,
+      getQueryCount,
+      noResults,
+      id,
+      className,
+      setRef,
+      chartType,
+      preTransformProps,
+      overrideTransformProps,
+      postTransformProps,
+      onRenderSuccess,
+      onRenderFailure,
+    ],
+  );
 
-    // Check for no results only for non-matrixified charts
-    const noResultQueries =
-      enableNoResults &&
-      (!queriesData ||
-        queriesData
-          .slice(0, this.getQueryCount())
-          .every(
-            ({ data }) => !data || (Array.isArray(data) && data.length === 0),
-          ));
+  const { heightInfo, widthInfo, BoundingBox } = parseDimension({
+    width,
+    height,
+  });
 
-    let chart;
-    if (noResultQueries) {
-      chart = noResults || (
-        <NoResultsComponent
-          id={id}
-          className={className}
-          height={height}
-          width={width}
-        />
-      );
-    } else {
-      const chartWithoutWrapper = (
-        <SuperChartCore
-          ref={this.setRef}
-          id={id}
-          className={className}
-          chartType={chartType}
-          chartProps={chartProps}
-          preTransformProps={preTransformProps}
-          overrideTransformProps={overrideTransformProps}
-          postTransformProps={postTransformProps}
-          onRenderSuccess={onRenderSuccess}
-          onRenderFailure={onRenderFailure}
-        />
-      );
-      chart = Wrapper ? (
-        <Wrapper width={width} height={height}>
-          {chartWithoutWrapper}
-        </Wrapper>
-      ) : (
-        chartWithoutWrapper
-      );
-    }
-    // Include the error boundary by default unless it is specifically disabled.
-    return disableErrorBoundary === true ? (
-      chart
-    ) : (
-      <ErrorBoundary
-        FallbackComponent={props => (
-          <FallbackComponent width={width} height={height} {...props} />
-        )}
-        onError={onErrorBoundary}
-      >
-        {chart}
-      </ErrorBoundary>
+  // If any of the dimension is dynamic, get parent's dimension
+  if (widthInfo.isDynamic || heightInfo.isDynamic) {
+    return (
+      <BoundingBox>
+        <ParentSize debounceTime={debounceTime}>
+          {({ width: parentWidth, height: parentHeight }) =>
+            renderChart(
+              widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
+              heightInfo.isDynamic
+                ? Math.floor(parentHeight)
+                : heightInfo.value,
+            )
+          }
+        </ParentSize>
+      </BoundingBox>
     );
   }
 
-  render() {
-    const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
-      this.props as PropsWithDefault,
-    );
-
-    // If any of the dimension is dynamic, get parent's dimension
-    if (widthInfo.isDynamic || heightInfo.isDynamic) {
-      const { debounceTime } = this.props;
-
-      return (
-        <BoundingBox>
-          <ParentSize debounceTime={debounceTime}>
-            {({ width, height }) =>
-              this.renderChart(
-                widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
-                heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
-              )
-            }
-          </ParentSize>
-        </BoundingBox>
-      );
-    }
-
-    return this.renderChart(widthInfo.value, heightInfo.value);
-  }
+  return renderChart(widthInfo.value, heightInfo.value);
 }
 
-export default withTheme(SuperChart);
+export default SuperChart;
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
index 9fea3c3153a..c999d1d3347 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
@@ -17,8 +17,13 @@
  * under the License.
  */
 
-/* eslint-disable react/jsx-sort-default-props */
-import { PureComponent } from 'react';
+import {
+  forwardRef,
+  useCallback,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+} from 'react';
 import { t } from '@apache-superset/core';
 import { createSelector } from 'reselect';
 import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
@@ -39,16 +44,6 @@ function IDENTITY<T>(x: T) {
 
 const EMPTY = () => null;
 
-const defaultProps = {
-  id: '',
-  className: '',
-  preTransformProps: IDENTITY,
-  overrideTransformProps: undefined,
-  postTransformProps: IDENTITY,
-  onRenderSuccess() {},
-  onRenderFailure() {},
-};
-
 interface LoadingProps {
   error: { toString(): string };
 }
@@ -78,174 +73,231 @@ export type Props = {
   onRenderFailure?: HandlerFunction;
 };
 
-export default class SuperChartCore extends PureComponent<Props, {}> {
-  /**
-   * The HTML element that wraps all chart content
-   */
-  container?: HTMLElement | null;
+export interface SuperChartCoreRef {
+  container: HTMLElement | null;
+}
 
-  /**
-   * memoized function so it will not recompute and return previous value
-   * unless one of
-   * - preTransformProps
-   * - chartProps
-   * is changed.
-   */
-  preSelector = createSelector(
-    [
-      (input: {
-        chartProps: ChartProps;
-        preTransformProps?: PreTransformProps;
-      }) => input.chartProps,
-      input => input.preTransformProps,
-    ],
-    (chartProps, pre = IDENTITY) => pre(chartProps),
-  );
+const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
+  function SuperChartCore(
+    {
+      id = '',
+      className = '',
+      chartProps = BLANK_CHART_PROPS,
+      chartType,
+      preTransformProps = IDENTITY,
+      overrideTransformProps,
+      postTransformProps = IDENTITY,
+      onRenderSuccess = () => {},
+      onRenderFailure = () => {},
+    },
+    ref,
+  ) {
+    const containerRef = useRef<HTMLElement | null>(null);
 
-  /**
-   * memoized function so it will not recompute and return previous value
-   * unless one of the input arguments have changed.
-   */
-  transformSelector = createSelector(
-    [
-      (input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
-        input.chartProps,
-      input => input.transformProps,
-    ],
-    (preprocessedChartProps, transform = IDENTITY) =>
-      transform(preprocessedChartProps),
-  );
+    // Expose container via ref
+    useImperativeHandle(
+      ref,
+      () => ({
+        get container() {
+          return containerRef.current;
+        },
+      }),
+      [],
+    );
 
-  /**
-   * memoized function so it will not recompute and return previous value
-   * unless one of the input arguments have changed.
-   */
-  postSelector = createSelector(
-    [
-      (input: {
-        chartProps: ChartProps;
-        postTransformProps?: PostTransformProps;
-      }) => input.chartProps,
-      input => input.postTransformProps,
-    ],
-    (transformedChartProps, post = IDENTITY) => post(transformedChartProps),
-  );
+    /**
+     * memoized function so it will not recompute and return previous value
+     * unless one of
+     * - preTransformProps
+     * - chartProps
+     * is changed.
+     */
+    const preSelector = useMemo(
+      () =>
+        createSelector(
+          [
+            (input: {
+              chartProps: ChartProps;
+              preTransformProps?: PreTransformProps;
+            }) => input.chartProps,
+            input => input.preTransformProps,
+          ],
+          (inputChartProps, pre = IDENTITY) => pre(inputChartProps),
+        ),
+      [],
+    );
 
-  /**
-   * Using each memoized function to retrieve the computed chartProps
-   */
-  processChartProps = ({
-    chartProps,
-    preTransformProps,
-    transformProps,
-    postTransformProps,
-  }: {
-    chartProps: ChartProps;
-    preTransformProps?: PreTransformProps;
-    transformProps?: TransformProps;
-    postTransformProps?: PostTransformProps;
-  }) =>
-    this.postSelector({
-      chartProps: this.transformSelector({
-        chartProps: this.preSelector({ chartProps, preTransformProps }),
-        transformProps,
-      }),
-      postTransformProps,
-    });
+    /**
+     * memoized function so it will not recompute and return previous value
+     * unless one of the input arguments have changed.
+     */
+    const transformSelector = useMemo(
+      () =>
+        createSelector(
+          [
+            (input: {
+              chartProps: ChartProps;
+              transformProps?: TransformProps;
+            }) => input.chartProps,
+            input => input.transformProps,
+          ],
+          (preprocessedChartProps, transform = IDENTITY) =>
+            transform(preprocessedChartProps),
+        ),
+      [],
+    );
 
-  /**
-   * memoized function so it will not recompute
-   * and return previous value
-   * unless one of
-   * - chartType
-   * - overrideTransformProps
-   * is changed.
-   */
-  private createLoadableRenderer = createSelector(
-    [
-      (input: { chartType: string; overrideTransformProps?: TransformProps }) =>
-        input.chartType,
-      input => input.overrideTransformProps,
-    ],
-    (chartType, overrideTransformProps) => {
-      if (chartType) {
-        const Renderer = createLoadableRenderer({
-          loader: {
-            Chart: () => getChartComponentRegistry().getAsPromise(chartType),
-            transformProps: overrideTransformProps
-              ? () => Promise.resolve(overrideTransformProps)
-              : () => getChartTransformPropsRegistry().getAsPromise(chartType),
-          },
-          loading: (loadingProps: LoadingProps) =>
-            this.renderLoading(loadingProps, chartType),
-          render: this.renderChart,
-        });
+    /**
+     * memoized function so it will not recompute and return previous value
+     * unless one of the input arguments have changed.
+     */
+    const postSelector = useMemo(
+      () =>
+        createSelector(
+          [
+            (input: {
+              chartProps: ChartProps;
+              postTransformProps?: PostTransformProps;
+            }) => input.chartProps,
+            input => input.postTransformProps,
+          ],
+          (transformedChartProps, post = IDENTITY) =>
+            post(transformedChartProps),
+        ),
+      [],
+    );
 
-        // Trigger preloading.
-        Renderer.preload();
+    /**
+     * Using each memoized function to retrieve the computed chartProps
+     */
+    const processChartProps = useCallback(
+      ({
+        chartProps: inputChartProps,
+        preTransformProps: pre,
+        transformProps,
+        postTransformProps: post,
+      }: {
+        chartProps: ChartProps;
+        preTransformProps?: PreTransformProps;
+        transformProps?: TransformProps;
+        postTransformProps?: PostTransformProps;
+      }) =>
+        postSelector({
+          chartProps: transformSelector({
+            chartProps: preSelector({
+              chartProps: inputChartProps,
+              preTransformProps: pre,
+            }),
+            transformProps,
+          }),
+          postTransformProps: post,
+        }),
+      [preSelector, transformSelector, postSelector],
+    );
 
-        return Renderer;
-      }
+    const renderLoading = useCallback(
+      (loadingProps: LoadingProps, loadingChartType: string) => {
+        const { error } = loadingProps;
 
-      return EMPTY;
-    },
-  );
+        if (error) {
+          return (
+            <div className="alert alert-warning" role="alert">
+              <strong>{t('ERROR')}</strong>&nbsp;
+              <code>chartType=&quot;{loadingChartType}&quot;</code> &mdash;
+              {error.toString()}
+            </div>
+          );
+        }
 
-  static defaultProps = defaultProps;
+        return null;
+      },
+      [],
+    );
 
-  private renderChart = (loaded: LoadedModules, props: RenderProps) => {
-    const { Chart, transformProps } = loaded;
-    const { chartProps, preTransformProps, postTransformProps } = props;
+    const renderChart = useCallback(
+      (loaded: LoadedModules, props: RenderProps) => {
+        const { Chart, transformProps } = loaded;
+        const {
+          chartProps: renderChartProps,
+          preTransformProps: pre,
+          postTransformProps: post,
+        } = props;
 
-    return (
-      <Chart
-        {...this.processChartProps({
-          chartProps,
-          preTransformProps,
-          transformProps,
-          postTransformProps,
-        })}
-      />
+        return (
+          <Chart
+            {...processChartProps({
+              chartProps: renderChartProps,
+              preTransformProps: pre,
+              transformProps,
+              postTransformProps: post,
+            })}
+          />
+        );
+      },
+      [processChartProps],
     );
-  };
 
-  private renderLoading = (loadingProps: LoadingProps, chartType: string) => {
-    const { error } = loadingProps;
+    /**
+     * memoized function so it will not recompute
+     * and return previous value
+     * unless one of
+     * - chartType
+     * - overrideTransformProps
+     * is changed.
+     */
+    const createLoadableRendererSelector = useMemo(
+      () =>
+        createSelector(
+          [
+            (input: {
+              chartType: string;
+              overrideTransformProps?: TransformProps;
+            }) => input.chartType,
+            input => input.overrideTransformProps,
+          ],
+          (selectorChartType, selectorOverrideTransformProps) => {
+            if (selectorChartType) {
+              const Renderer = createLoadableRenderer({
+                loader: {
+                  Chart: () =>
+                    getChartComponentRegistry().getAsPromise(selectorChartType),
+                  transformProps: selectorOverrideTransformProps
+                    ? () => Promise.resolve(selectorOverrideTransformProps)
+                    : () =>
+                        getChartTransformPropsRegistry().getAsPromise(
+                          selectorChartType,
+                        ),
+                },
+                loading: (loadingProps: LoadingProps) =>
+                  renderLoading(loadingProps, selectorChartType),
+                render: renderChart,
+              });
 
-    if (error) {
-      return (
-        <div className="alert alert-warning" role="alert">
-          <strong>{t('ERROR')}</strong>&nbsp;
-          <code>chartType=&quot;{chartType}&quot;</code> &mdash;
-          {error.toString()}
-        </div>
-      );
-    }
+              // Trigger preloading.
+              Renderer.preload();
 
-    return null;
-  };
+              return Renderer;
+            }
 
-  private setRef = (container: HTMLElement | null) => {
-    this.container = container;
-  };
+            return EMPTY;
+          },
+        ),
+      [renderLoading, renderChart],
+    );
 
-  render() {
-    const {
-      id,
-      className,
-      preTransformProps,
-      postTransformProps,
-      chartProps = BLANK_CHART_PROPS,
-      onRenderSuccess,
-      onRenderFailure,
-    } = this.props;
+    const setRef = useCallback((container: HTMLElement | null) => {
+      containerRef.current = container;
+    }, []);
 
     // Create LoadableRenderer and start preloading
     // the lazy-loaded Chart components
-    const Renderer = this.createLoadableRenderer(this.props);
+    const Renderer = createLoadableRendererSelector({
+      chartType,
+      overrideTransformProps,
+    });
 
     // Do not render if chartProps is set to null.
-    // but the pre-loading has been started in this.createLoadableRenderer
+    // but the pre-loading has been started in createLoadableRendererSelector
     // to prepare for rendering once chartProps becomes available.
     if (chartProps === null) {
       return null;
@@ -263,7 +315,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
     }
 
     return (
-      <div {...containerProps} ref={this.setRef}>
+      <div {...containerProps} ref={setRef}>
         <Renderer
           preTransformProps={preTransformProps}
           postTransformProps={postTransformProps}
@@ -273,5 +325,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
         />
       </div>
     );
-  }
-}
+  },
+);
+
+export default SuperChartCore;
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
index c52f13d0de0..75636b7b2ff 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
@@ -17,8 +17,13 @@
  * under the License.
  */
 
-// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
-import { Component, ComponentClass, WeakValidationMap } from 'react';
+import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
+import type {
+  WeakValidationMap,
+  ForwardRefExoticComponent,
+  PropsWithoutRef,
+  RefAttributes,
+} from 'react';
 
 // TODO: Note that id and className can collide between Props and ReactifyProps
 // leading to (likely) unexpected behaviors. We should either require Props to not
@@ -49,66 +54,78 @@ export interface RenderFuncType<Props> {
   propTypes?: WeakValidationMap<Props & ReactifyProps>;
 }
 
+export interface ReactifiedComponentRef {
+  container?: HTMLDivElement;
+}
+
+type ReactifiedComponent<Props> = ForwardRefExoticComponent<
+  PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
+> & {
+  defaultProps?: Partial<Props & ReactifyProps>;
+  propTypes?: WeakValidationMap<Props & ReactifyProps>;
+};
+
 export default function reactify<Props extends object>(
   renderFn: RenderFuncType<Props>,
   callbacks?: LifeCycleCallbacks,
-): ComponentClass<Props & ReactifyProps> {
-  class ReactifiedComponent extends Component<Props & ReactifyProps> {
-    container?: HTMLDivElement;
-
-    constructor(props: Props & ReactifyProps) {
-      super(props);
-      this.setContainerRef = this.setContainerRef.bind(this);
-    }
-
-    componentDidMount() {
-      this.execute();
-    }
-
-    componentDidUpdate() {
-      this.execute();
-    }
-
-    componentWillUnmount() {
-      this.container = undefined;
-      if (callbacks?.componentWillUnmount) {
-        callbacks.componentWillUnmount.bind(this)();
+): ReactifiedComponent<Props> {
+  const ReactifiedComponent = forwardRef<
+    ReactifiedComponentRef,
+    Props & ReactifyProps
+  >(function ReactifiedComponent(props, ref) {
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    // Expose container via ref for external access
+    useImperativeHandle(
+      ref,
+      () => ({
+        get container() {
+          return containerRef.current ?? undefined;
+        },
+      }),
+      [],
+    );
+
+    // Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
+    useEffect(() => {
+      if (containerRef.current) {
+        renderFn(containerRef.current, props);
       }
-    }
+    });
 
-    setContainerRef(ref: HTMLDivElement) {
-      this.container = ref;
-    }
+    // Cleanup on unmount
+    useEffect(
+      () => () => {
+        if (callbacks?.componentWillUnmount) {
+          callbacks.componentWillUnmount();
+        }
+      },
+      [],
+    );
 
-    execute() {
-      if (this.container) {
-        renderFn(this.container, this.props);
-      }
-    }
+    const { id, className } = props;
 
-    render() {
-      const { id, className } = this.props;
+    return <div ref={containerRef} id={id} className={className} />;
+  });
 
-      return <div ref={this.setContainerRef} id={id} className={className} />;
-    }
+  if (renderFn.displayName) {
+    ReactifiedComponent.displayName = renderFn.displayName;
   }
 
-  const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
-    ReactifiedComponent;
+  // Cast to any to assign propTypes and defaultProps since forwardRef
+  // components have complex typing that makes direct assignment difficult
+  const result = ReactifiedComponent as any;
 
-  if (renderFn.displayName) {
-    ReactifiedClass.displayName = renderFn.displayName;
-  }
-  // eslint-disable-next-line react/forbid-foreign-prop-types
   if (renderFn.propTypes) {
-    ReactifiedClass.propTypes = {
-      ...ReactifiedClass.propTypes,
+    result.propTypes = {
+      ...result.propTypes,
       ...renderFn.propTypes,
     };
   }
+
   if (renderFn.defaultProps) {
-    ReactifiedClass.defaultProps = renderFn.defaultProps;
+    result.defaultProps = renderFn.defaultProps;
   }
 
-  return ReactifiedComponent;
+  return result as ReactifiedComponent<Props>;
 }
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx b/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
index e053f3c393d..809ff90564c 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
@@ -227,15 +227,27 @@ describe('SuperChartCore', () => {
     });
   });
 
-  describe('.processChartProps()', () => {
-    test('use identity functions for unspecified transforms', () => {
-      const chart = new SuperChartCore({
-        chartType: ChartKeys.DILIGENT,
+  describe('processChartProps behavior', () => {
+    test('passes through chartProps unchanged when no transforms are specified', async () => {
+      // When no pre/post transform props are specified, the identity function is used
+      // which means chartProps should pass through to the chart unchanged.
+      // We verify this by checking that the chart renders correctly without transforms.
+      const chartProps2 = new ChartProps({
+        queriesData: [{ message: 'identity-test' }],
+        theme: supersetTheme,
       });
-      const chartProps2 = new ChartProps();
-      expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
-        chartProps2,
+
+      render(
+        <SuperChartCore
+          chartType={ChartKeys.DILIGENT}
+          chartProps={chartProps2}
+          overrideTransformProps={props => props.queriesData[0]}
+        />,
       );
+
+      await waitFor(() => {
+        expect(screen.getByText('identity-test')).toBeInTheDocument();
+      });
     });
   });
 });
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
index b64e1989411..fddbf59289d 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
@@ -19,9 +19,9 @@
 
 import '@testing-library/jest-dom';
 import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
+import { useEffect, useState } from 'react';
 import { reactify } from '@superset-ui/core';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
 import { RenderFuncType } from '../../../src/chart/components/reactify';
 
 describe('reactify(renderFn)', () => {
@@ -52,48 +52,36 @@ describe('reactify(renderFn)', () => {
     componentWillUnmount: willUnmountCb,
   });
 
-  class TestComponent extends PureComponent<{}, { content: string }> {
-    constructor(props = {}) {
-      super(props);
-      this.state = { content: 'abc' };
-    }
+  function TestComponent() {
+    const [content, setContent] = useState('abc');
 
-    componentDidMount() {
-      setTimeout(() => {
-        this.setState({ content: 'def' });
+    useEffect(() => {
+      const timer = setTimeout(() => {
+        setContent('def');
       }, 10);
-    }
+      return () => clearTimeout(timer);
+    }, []);
 
-    render() {
-      const { content } = this.state;
-
-      return <TheChart id="test" content={content} />;
-    }
+    return <TheChart id="test" content={content} />;
   }
 
-  class AnotherTestComponent extends PureComponent<{}, {}> {
-    render() {
-      return <TheChartWithWillUnmountHook id="another_test" />;
-    }
+  function AnotherTestComponent() {
+    return <TheChartWithWillUnmountHook id="another_test" />;
   }
 
-  test('returns a React component class', () =>
-    new Promise(done => {
-      render(<TestComponent />);
+  test('returns a React component and re-renders on prop changes', async () => {
+    render(<TestComponent />);
+
+    expect(renderFn).toHaveBeenCalledTimes(1);
+    expect(screen.getByText('abc')).toBeInTheDocument();
+    expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
 
-      expect(renderFn).toHaveBeenCalledTimes(1);
-      expect(screen.getByText('abc')).toBeInTheDocument();
-      expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
-      setTimeout(() => {
-        expect(renderFn).toHaveBeenCalledTimes(2);
-        expect(screen.getByText('def')).toBeInTheDocument();
-        expect(screen.getByText('def').parentNode).toHaveAttribute(
-          'id',
-          'test',
-        );
-        done(undefined);
-      }, 20);
-    }));
+    await waitFor(() => {
+      expect(screen.getByText('def')).toBeInTheDocument();
+    });
+    expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
+    expect(renderFn).toHaveBeenCalledTimes(2);
+  });
   describe('displayName', () => {
     test('has displayName if renderFn.displayName is defined', () => {
       expect(TheChart.displayName).toEqual('BoldText');
@@ -126,20 +114,16 @@ describe('reactify(renderFn)', () => {
       expect(AnotherChart.defaultProps).toBeUndefined();
     });
   });
-  test('does not try to render if not mounted', () => {
+  test('calls renderFn when container is set', () => {
     const anotherRenderFn = jest.fn();
-    const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call
-    // @ts-expect-error
-    new AnotherChart({ id: 'test' }).execute();
-    expect(anotherRenderFn).not.toHaveBeenCalled();
+    const AnotherChart = reactify(anotherRenderFn);
+    const { unmount } = render(<AnotherChart id="test" />);
+    expect(anotherRenderFn).toHaveBeenCalled();
+    unmount();
+  });
+  test('calls willUnmount hook when it is provided', async () => {
+    const { unmount } = render(<AnotherTestComponent />);
+    unmount();
+    expect(willUnmountCb).toHaveBeenCalledTimes(1);
   });
-  test('calls willUnmount hook when it is provided', () =>
-    new Promise(done => {
-      const { unmount } = render(<AnotherTestComponent />);
-      setTimeout(() => {
-        unmount();
-        expect(willUnmountCb).toHaveBeenCalledTimes(1);
-        done(undefined);
-      }, 20);
-    }));
 });
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
index 37b177f325b..cea87063bd7 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
@@ -17,45 +17,32 @@
  * under the License.
  */
 
-import { Component, ReactNode } from 'react';
+import { useState, useCallback, ReactNode } from 'react';
 
 export type Props = {
   children: ReactNode;
   expandableWhat?: string;
 };
 
-type State = {
-  open: boolean;
-};
-
-export default class Expandable extends Component<Props, State> {
-  constructor(props: Props) {
-    super(props);
-    this.state = { open: false };
-    this.handleToggle = this.handleToggle.bind(this);
-  }
-
-  handleToggle() {
-    this.setState(({ open }) => ({ open: !open }));
-  }
+export default function Expandable({ children, expandableWhat }: Props) {
+  const [open, setOpen] = useState(false);
 
-  render() {
-    const { open } = this.state;
-    const { children, expandableWhat } = this.props;
+  const handleToggle = useCallback(() => {
+    setOpen(prevOpen => !prevOpen);
+  }, []);
 
-    return (
-      <div>
-        <button
-          type="button"
-          className="btn btn-primary btn-sm"
-          onClick={this.handleToggle}
-        >
-          {`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
-        </button>
-        <br />
-        <br />
-        {open ? children : null}
-      </div>
-    );
-  }
+  return (
+    <div>
+      <button
+        type="button"
+        className="btn btn-primary btn-sm"
+        onClick={handleToggle}
+      >
+        {`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
+      </button>
+      <br />
+      <br />
+      {open ? children : null}
+    </div>
+  );
 }
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
index bbc3e115bf6..1803439bbb5 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { Component, ReactNode } from 'react';
+import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
 import { t } from '@apache-superset/core';
 import {
   SupersetClient,
@@ -36,12 +36,6 @@ export type Props = {
   postPayload?: string;
 };
 
-type State = {
-  didVerify: boolean;
-  error?: Error | SupersetApiError;
-  payload?: object;
-};
-
 export const renderError = (error: Error) => (
   <div>
     The following error occurred, make sure you have <br />
@@ -54,29 +48,37 @@ export const renderError = (error: Error) => (
   </div>
 );
 
-export default class VerifyCORS extends Component<Props, State> {
-  constructor(props: Props) {
-    super(props);
-    this.state = { didVerify: false };
-    this.handleVerify = this.handleVerify.bind(this);
-  }
+export default function VerifyCORS({
+  children,
+  endpoint,
+  host,
+  method,
+  postPayload,
+}: Props): JSX.Element {
+  const [didVerify, setDidVerify] = useState(false);
+  const [error, setError] = useState<Error | SupersetApiError | undefined>(
+    undefined,
+  );
+  const [payload, setPayload] = useState<object | undefined>(undefined);
 
-  componentDidUpdate(prevProps: Props) {
-    const { endpoint, host, postPayload, method } = this.props;
+  const prevPropsRef = useRef({ endpoint, host, postPayload, method });
+
+  useEffect(() => {
+    const prevProps = prevPropsRef.current;
     if (
-      (this.state.didVerify || this.state.error) &&
+      (didVerify || error) &&
       (prevProps.endpoint !== endpoint ||
         prevProps.host !== host ||
         prevProps.postPayload !== postPayload ||
         prevProps.method !== method)
     ) {
-      // eslint-disable-next-line react/no-did-update-set-state
-      this.setState({ didVerify: false, error: undefined });
+      setDidVerify(false);
+      setError(undefined);
     }
-  }
+    prevPropsRef.current = { endpoint, host, postPayload, method };
+  }, [endpoint, host, postPayload, method, didVerify, error]);
 
-  handleVerify() {
-    const { endpoint, host, postPayload, method } = this.props;
+  const handleVerify = useCallback(() => {
     SupersetClient.reset();
     SupersetClient.configure({
       credentials: 'include',
@@ -94,43 +96,40 @@ export default class VerifyCORS extends Component<Props, State> {
         }
         return { error: 'Must provide valid endpoint and payload.' };
       })
-      .then(result =>
-        this.setState({ didVerify: true, error: undefined, payload: result }),
-      )
-      .catch(error => this.setState({ error }));
-  }
+      .then(result => {
+        setDidVerify(true);
+        setError(undefined);
+        setPayload(result);
+      })
+      .catch(err => setError(err));
+  }, [endpoint, host, method, postPayload]);
 
-  render() {
-    const { didVerify, error, payload } = this.state;
-    const { children } = this.props;
+  return didVerify ? (
+    <>{children({ payload })}</>
+  ) : (
+    <div className="row">
+      <div className="col-md-10">
+        This example requires CORS requests from this domain. <br />
+        <br />
+        1) enable CORS requests in your Superset App from{' '}
+        {`${window.location.origin}`}
+        <br />
+        2) configure your Superset App host name below <br />
+        3) click below to verify authentication. You may debug CORS further
+        using the `@superset-ui/connection` story. <br />
+        <br />
+        <Button type="primary" size="small" onClick={handleVerify}>
+          {t('Verify')}
+        </Button>
+        <br />
+        <br />
+      </div>
 
-    return didVerify ? (
-      children({ payload })
-    ) : (
-      <div className="row">
-        <div className="col-md-10">
-          This example requires CORS requests from this domain. <br />
-          <br />
-          1) enable CORS requests in your Superset App from{' '}
-          {`${window.location.origin}`}
-          <br />
-          2) configure your Superset App host name below <br />
-          3) click below to verify authentication. You may debug CORS further
-          using the `@superset-ui/connection` story. <br />
-          <br />
-          <Button type="primary" size="small" onClick={this.handleVerify}>
-            {t('Verify')}
-          </Button>
-          <br />
-          <br />
+      {error && (
+        <div className="col-md-8">
+          <ErrorMessage error={error} />
         </div>
-
-        {error && (
-          <div className="col-md-8">
-            <ErrorMessage error={error} />
-          </div>
-        )}
-      </div>
-    );
-  }
+      )}
+    </div>
+  );
 }
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
index 718a1f17cfb..f473a8f8df2 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
@@ -17,126 +17,108 @@
  * under the License.
  */
 
-import { PureComponent } from 'react';
+import { useState, useCallback } from 'react';
 import { formatNumber } from '@superset-ui/core';
 
-interface NumberFormatValidatorState {
-  formatString: string;
-  testValues: (number | null | undefined)[];
-}
-
-class NumberFormatValidator extends PureComponent<
-  Record<string, never>,
-  NumberFormatValidatorState
-> {
-  state: NumberFormatValidatorState = {
-    formatString: '.3~s',
-    testValues: [
-      987654321,
-      12345.6789,
-      3000,
-      400.14,
-      70.00002,
-      1,
-      0,
-      -1,
-      -70.00002,
-      -400.14,
-      -3000,
-      -12345.6789,
-      -987654321,
-      Number.POSITIVE_INFINITY,
-      Number.NEGATIVE_INFINITY,
-      NaN,
-      null,
-      undefined,
-    ],
-  };
-
-  constructor(props: Record<string, never>) {
-    super(props);
-
-    this.handleFormatChange = this.handleFormatChange.bind(this);
-  }
+const testValues: (number | null | undefined)[] = [
+  987654321,
+  12345.6789,
+  3000,
+  400.14,
+  70.00002,
+  1,
+  0,
+  -1,
+  -70.00002,
+  -400.14,
+  -3000,
+  -12345.6789,
+  -987654321,
+  Number.POSITIVE_INFINITY,
+  Number.NEGATIVE_INFINITY,
+  NaN,
+  null,
+  undefined,
+];
 
-  handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
-    this.setState({
-      formatString: event.target.value,
-    });
-  }
+function NumberFormatValidator() {
+  const [formatString, setFormatString] = useState('.3~s');
 
-  render() {
-    const { formatString, testValues } = this.state;
+  const handleFormatChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      setFormatString(event.target.value);
+    },
+    [],
+  );
 
-    return (
-      <div className="container">
-        <div className="row" style={{ margin: '40px 20px 0 20px' }}>
-          <div className="col-sm">
-            <p>
-              This <code>@superset-ui/number-format</code> package enriches{' '}
-              <code>d3-format</code>
-              to handle invalid formats as well as edge case values. Use the
-              validator below to preview outputs from the specified format
-              string. See
-              <a
-                href="https://github.com/d3/d3-format#locale_format"
-                target="_blank"
-                rel="noopener noreferrer"
-              >
-                D3 Format Reference
-              </a>
-              for how to write a D3 format string.
-            </p>
-          </div>
+  return (
+    <div className="container">
+      <div className="row" style={{ margin: '40px 20px 0 20px' }}>
+        <div className="col-sm">
+          <p>
+            This <code>@superset-ui/number-format</code> package enriches{' '}
+            <code>d3-format</code>
+            to handle invalid formats as well as edge case values. Use the
+            validator below to preview outputs from the specified format string.
+            See
+            <a
+              href="https://github.com/d3/d3-format#locale_format"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              D3 Format Reference
+            </a>
+            for how to write a D3 format string.
+          </p>
         </div>
-        <div className="row" style={{ margin: '10px 0 30px 0' }}>
-          <div className="col-sm" />
-          <div className="col-sm-8">
-            <div className="form">
-              <div className="form-group">
-                {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
-                <label>
-                  Enter D3 format string:
-                  <input
-                    id="formatString"
-                    className="form-control form-control-lg"
-                    type="text"
-                    value={formatString}
-                    onChange={this.handleFormatChange}
-                  />
-                </label>
-              </div>
+      </div>
+      <div className="row" style={{ margin: '10px 0 30px 0' }}>
+        <div className="col-sm" />
+        <div className="col-sm-8">
+          <div className="form">
+            <div className="form-group">
+              {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+              <label>
+                Enter D3 format string:
+                <input
+                  id="formatString"
+                  className="form-control form-control-lg"
+                  type="text"
+                  value={formatString}
+                  onChange={handleFormatChange}
+                />
+              </label>
             </div>
           </div>
-          <div className="col-sm" />
         </div>
-        <div className="row">
-          <div className="col-sm">
-            <table className="table table-striped table-sm">
-              <thead>
-                <tr>
-                  <th>Input (number)</th>
-                  <th>Formatted output (string)</th>
+        <div className="col-sm" />
+      </div>
+      <div className="row">
+        <div className="col-sm">
+          <table className="table table-striped table-sm">
+            <thead>
+              <tr>
+                <th>Input (number)</th>
+                <th>Formatted output (string)</th>
+              </tr>
+            </thead>
+            <tbody>
+              {testValues.map((v, index) => (
+                <tr key={index}>
+                  <td>
+                    <code>{`${v}`}</code>
+                  </td>
+                  <td>
+                    <code>&quot;{formatNumber(formatString, v)}&quot;</code>
+                  </td>
                 </tr>
-              </thead>
-              <tbody>
-                {testValues.map((v, index) => (
-                  <tr key={index}>
-                    <td>
-                      <code>{`${v}`}</code>
-                    </td>
-                    <td>
-                      <code>&quot;{formatNumber(formatString, v)}&quot;</code>
-                    </td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          </div>
+              ))}
+            </tbody>
+          </table>
         </div>
       </div>
-    );
-  }
+    </div>
+  );
 }
 
 export default {
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
index 2798113b587..34ee21dee46 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
@@ -17,115 +17,96 @@
  * under the License.
  */
 
-import { PureComponent } from 'react';
+import { useState, useCallback } from 'react';
 import { formatTime } from '@superset-ui/core';
 
-interface TimeFormatValidatorState {
-  formatString: string;
-  testValues: (Date | number | null | undefined)[];
-}
-
-class TimeFormatValidator extends PureComponent<
-  Record<string, never>,
-  TimeFormatValidatorState
-> {
-  state: TimeFormatValidatorState = {
-    formatString: '%Y-%m-%d %H:%M:%S',
-    testValues: [
-      new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
-      new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
-      new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
-      new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
-      0,
-      null,
-      undefined,
-    ],
-  };
+const testValues: (Date | number | null | undefined)[] = [
+  new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
+  new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
+  new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
+  new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
+  0,
+  null,
+  undefined,
+];
 
-  constructor(props: Record<string, never>) {
-    super(props);
-    this.handleFormatChange = this.handleFormatChange.bind(this);
-  }
+function TimeFormatValidator() {
+  const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S');
 
-  handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
-    this.setState({
-      formatString: event.target.value,
-    });
-  }
+  const handleFormatChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      setFormatString(event.target.value);
+    },
+    [],
+  );
 
-  render() {
-    const { formatString, testValues } = this.state;
-
-    return (
-      <div className="container">
-        <div className="row" style={{ margin: '40px 20px 0 20px' }}>
-          <div className="col-sm">
-            <p>
-              This <code>@superset-ui/time-format</code> package enriches
-              <code>d3-time-format</code> to handle invalid formats as well as
-              edge case values. Use the validator below to preview outputs from
-              the specified format string. See &nbsp;
-              <a
-                href="https://github.com/d3/d3-time-format#locale_format"
-                target="_blank"
-                rel="noopener noreferrer"
-              >
-                D3 Time Format Reference
-              </a>
-              &nbsp;for how to write a D3 time format string.
-            </p>
-          </div>
+  return (
+    <div className="container">
+      <div className="row" style={{ margin: '40px 20px 0 20px' }}>
+        <div className="col-sm">
+          <p>
+            This <code>@superset-ui/time-format</code> package enriches
+            <code>d3-time-format</code> to handle invalid formats as well as
+            edge case values. Use the validator below to preview outputs from
+            the specified format string. See &nbsp;
+            <a
+              href="https://github.com/d3/d3-time-format#locale_format"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              D3 Time Format Reference
+            </a>
+            &nbsp;for how to write a D3 time format string.
+          </p>
         </div>
-        <div className="row" style={{ margin: '10px 0 30px 0' }}>
-          <div className="col-sm" />
-          <div className="col-sm-8">
-            <div className="form">
-              <div className="form-group">
-                {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
-                <label>
-                  Enter D3 time format string:
-                  <input
-                    id="formatString"
-                    className="form-control form-control-lg"
-                    type="text"
-                    value={formatString}
-                    onChange={this.handleFormatChange}
-                  />
-                </label>
-              </div>
+      </div>
+      <div className="row" style={{ margin: '10px 0 30px 0' }}>
+        <div className="col-sm" />
+        <div className="col-sm-8">
+          <div className="form">
+            <div className="form-group">
+              {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+              <label>
+                Enter D3 time format string:
+                <input
+                  id="formatString"
+                  className="form-control form-control-lg"
+                  type="text"
+                  value={formatString}
+                  onChange={handleFormatChange}
+                />
+              </label>
             </div>
           </div>
-          <div className="col-sm" />
         </div>
-        <div className="row">
-          <div className="col-sm">
-            <table className="table table-striped table-sm">
-              <thead>
-                <tr>
-                  <th>Input (time)</th>
-                  <th>Formatted output (string)</th>
+        <div className="col-sm" />
+      </div>
+      <div className="row">
+        <div className="col-sm">
+          <table className="table table-striped table-sm">
+            <thead>
+              <tr>
+                <th>Input (time)</th>
+                <th>Formatted output (string)</th>
+              </tr>
+            </thead>
+            <tbody>
+              {testValues.map((v, index) => (
+                <tr key={index}>
+                  <td>
+                    <code>{v instanceof Date ? v.toUTCString() : `${v}`}</code>
+                  </td>
+                  <td>
+                    <code>&quot;{formatTime(formatString, v)}&quot;</code>
+                  </td>
                 </tr>
-              </thead>
-              <tbody>
-                {testValues.map((v, index) => (
-                  <tr key={index}>
-                    <td>
-                      <code>
-                        {v instanceof Date ? v.toUTCString() : `${v}`}
-                      </code>
-                    </td>
-                    <td>
-                      <code>&quot;{formatTime(formatString, v)}&quot;</code>
-                    </td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          </div>
+              ))}
+            </tbody>
+          </table>
         </div>
       </div>
-    );
-  }
+    </div>
+  );
 }
 
 export default {
diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
index 0ec77b51b89..2ef267ef336 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
@@ -16,8 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
-import { PureComponent } from 'react';
+import { memo, useMemo } from 'react';
 import { extent as d3Extent } from 'd3-array';
 import { ensureIsArray } from '@superset-ui/core';
 import { styled } from '@apache-superset/core/ui';
@@ -45,18 +44,6 @@ interface HorizonChartProps {
   offsetX?: number;
 }
 
-const defaultProps: Partial<HorizonChartProps> = {
-  className: '',
-  width: 800,
-  height: 600,
-  seriesHeight: 20,
-  bands: Math.floor(DEFAULT_COLORS.length / 2),
-  colors: DEFAULT_COLORS,
-  colorScale: 'series',
-  mode: 'offset',
-  offsetX: 0,
-};
-
 const StyledDiv = styled.div`
   ${({ theme }) => `
     .superset-legacy-chart-horizon {
@@ -80,24 +67,19 @@ const StyledDiv = styled.div`
   `}
 `;
 
-class HorizonChart extends PureComponent<HorizonChartProps> {
-  static defaultProps = defaultProps;
-
-  render() {
-    const {
-      className,
-      width,
-      height,
-      data,
-      seriesHeight,
-      bands,
-      colors,
-      colorScale,
-      mode,
-      offsetX,
-    } = this.props;
-
-    let yDomain: [number, number] | undefined;
+function HorizonChart({
+  className = '',
+  width = 800,
+  height = 600,
+  seriesHeight = 20,
+  data,
+  bands = Math.floor(DEFAULT_COLORS.length / 2),
+  colors = DEFAULT_COLORS,
+  colorScale = 'series',
+  mode = 'offset',
+  offsetX = 0,
+}: HorizonChartProps) {
+  const yDomain = useMemo((): [number, number] | undefined => {
     if (colorScale === 'overall') {
       const allValues = data.reduce<DataValue[]>(
         (acc, current) => acc.concat(current.values),
@@ -106,35 +88,36 @@ class HorizonChart extends PureComponent<HorizonChartProps> {
       const rawExtent = d3Extent(allValues, d => d.y);
       // Only set yDomain if we have valid min and max values
       if (rawExtent[0] != null && rawExtent[1] != null) {
-        yDomain = [rawExtent[0], rawExtent[1]];
+        return [rawExtent[0], rawExtent[1]];
       }
     }
+    return undefined;
+  }, [colorScale, data]);
 
-    return (
-      <StyledDiv>
-        <div
-          className={`superset-legacy-chart-horizon ${className}`}
-          style={{ height }}
-        >
-          {data.map(row => (
-            <HorizonRow
-              key={row.key.join(',')}
-              width={width}
-              height={seriesHeight}
-              title={ensureIsArray(row.key).join(', ')}
-              data={row.values}
-              bands={bands}
-              colors={colors}
-              colorScale={colorScale}
-              mode={mode}
-              offsetX={offsetX}
-              yDomain={yDomain}
-            />
-          ))}
-        </div>
-      </StyledDiv>
-    );
-  }
+  return (
+    <StyledDiv>
+      <div
+        className={`superset-legacy-chart-horizon ${className}`}
+        style={{ height }}
+      >
+        {data.map(row => (
+          <HorizonRow
+            key={row.key.join(',')}
+            width={width}
+            height={seriesHeight}
+            title={ensureIsArray(row.key).join(', ')}
+            data={row.values}
+            bands={bands}
+            colors={colors}
+            colorScale={colorScale}
+            mode={mode}
+            offsetX={offsetX}
+            yDomain={yDomain}
+          />
+        ))}
+      </div>
+    </StyledDiv>
+  );
 }
 
-export default HorizonChart;
+export default memo(HorizonChart);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
index f399d850d7c..7e988865fdc 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
@@ -17,9 +17,7 @@
  * under the License.
  */
 /* eslint-disable no-continue, no-bitwise */
-/* eslint-disable react/jsx-sort-default-props */
-/* eslint-disable react/sort-prop-types */
-import { PureComponent } from 'react';
+import { useRef, useEffect, useCallback, memo } from 'react';
 import { extent as d3Extent } from 'd3-array';
 import { scaleLinear } from 'd3-scale';
 
@@ -52,162 +50,140 @@ interface HorizonRowProps {
   yDomain?: [number, number];
 }
 
-const defaultProps: Partial<HorizonRowProps> = {
-  className: '',
-  width: 800,
-  height: 20,
-  bands: DEFAULT_COLORS.length >> 1,
-  colors: DEFAULT_COLORS,
-  colorScale: 'series',
-  mode: 'offset',
-  offsetX: 0,
-  title: '',
-  yDomain: undefined,
-};
-
-class HorizonRow extends PureComponent<HorizonRowProps> {
-  static defaultProps = defaultProps;
-
-  private canvas: HTMLCanvasElement | null = null;
-
-  componentDidMount() {
-    this.drawChart();
-  }
-
-  componentDidUpdate() {
-    this.drawChart();
-  }
-
-  componentWillUnmount() {
-    this.canvas = null;
-  }
-
-  drawChart() {
-    if (this.canvas) {
-      const {
-        data: rawData,
-        yDomain,
-        width = 800,
-        height = 20,
-        bands = DEFAULT_COLORS.length >> 1,
-        colors = DEFAULT_COLORS,
-        colorScale,
-        offsetX = 0,
-        mode,
-      } = this.props;
-
-      const data =
-        colorScale === 'change'
-          ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
-          : rawData;
-
-      const context = this.canvas.getContext('2d');
-      if (!context) return;
-      context.imageSmoothingEnabled = false;
-      context.clearRect(0, 0, width, height);
-      // Reset transform
-      context.setTransform(1, 0, 0, 1, 0, 0);
-      context.translate(0.5, 0.5);
-
-      const step = width / data.length;
-      // the data frame currently being shown:
-      const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
-      const endIndex = Math.floor(
-        Math.min(data.length, startIndex + width / step),
-      );
-
-      // skip drawing if there's no data to be drawn
-      if (startIndex > data.length) {
-        return;
+function HorizonRow({
+  className = '',
+  width = 800,
+  height = 20,
+  data: rawData,
+  bands = DEFAULT_COLORS.length >> 1,
+  colors = DEFAULT_COLORS,
+  colorScale = 'series',
+  mode = 'offset',
+  offsetX = 0,
+  title = '',
+  yDomain,
+}: HorizonRowProps) {
+  const canvasRef = useRef<HTMLCanvasElement | null>(null);
+
+  const drawChart = useCallback(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+
+    const data =
+      colorScale === 'change'
+        ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
+        : rawData;
+
+    const context = canvas.getContext('2d');
+    if (!context) return;
+    context.imageSmoothingEnabled = false;
+    context.clearRect(0, 0, width, height);
+    // Reset transform
+    context.setTransform(1, 0, 0, 1, 0, 0);
+    context.translate(0.5, 0.5);
+
+    const step = width / data.length;
+    // the data frame currently being shown:
+    const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
+    const endIndex = Math.floor(
+      Math.min(data.length, startIndex + width / step),
+    );
+
+    // skip drawing if there's no data to be drawn
+    if (startIndex > data.length) {
+      return;
+    }
+
+    // Create y-scale
+    const [min, max] =
+      yDomain || (d3Extent(data, d => d.y) as [number, number]);
+    const y = scaleLinear()
+      .domain([0, Math.max(-min, max)])
+      .range([0, height]);
+
+    // we are drawing positive & negative bands separately to avoid mutating canvas state
+    // http://www.html5rocks.com/en/tutorials/canvas/performance/
+    let hasNegative = false;
+    // draw positive bands
+    let value: number;
+    let bExtents: number;
+    for (let b = 0; b < bands; b += 1) {
+      context.fillStyle = colors[bands + b];
+
+      // Adjust the range based on the current band index.
+      bExtents = (b + 1 - bands) * height;
+      y.range([bands * height + bExtents, bExtents]);
+
+      // only the current data frame is being drawn i.e. what's visible:
+      for (let i = startIndex; i < endIndex; i += 1) {
+        value = data[i].y;
+        if (value <= 0) {
+          hasNegative = true;
+          continue;
+        }
+        if (value !== undefined) {
+          context.fillRect(
+            offsetX + i * step,
+            y(value)!,
+            step + 1,
+            y(0)! - y(value)!,
+          );
+        }
+      }
+    }
+
+    // draw negative bands
+    if (hasNegative) {
+      // mirror the negative bands, by flipping the canvas
+      if (mode === 'offset') {
+        context.translate(0, height);
+        context.scale(1, -1);
       }
 
-      // Create y-scale
-      const [min, max] =
-        yDomain || (d3Extent(data, d => d.y) as [number, number]);
-      const y = scaleLinear()
-        .domain([0, Math.max(-min, max)])
-        .range([0, height]);
-
-      // we are drawing positive & negative bands separately to avoid mutating canvas state
-      // http://www.html5rocks.com/en/tutorials/canvas/performance/
-      let hasNegative = false;
-      // draw positive bands
-      let value: number;
-      let bExtents: number;
       for (let b = 0; b < bands; b += 1) {
-        context.fillStyle = colors[bands + b];
+        context.fillStyle = colors[bands - b - 1];
 
         // Adjust the range based on the current band index.
         bExtents = (b + 1 - bands) * height;
         y.range([bands * height + bExtents, bExtents]);
 
         // only the current data frame is being drawn i.e. what's visible:
-        for (let i = startIndex; i < endIndex; i += 1) {
-          value = data[i].y;
-          if (value <= 0) {
-            hasNegative = true;
+        for (let ii = startIndex; ii < endIndex; ii += 1) {
+          value = data[ii].y;
+          if (value >= 0) {
             continue;
           }
-          if (value !== undefined) {
-            context.fillRect(
-              offsetX + i * step,
-              y(value)!,
-              step + 1,
-              y(0)! - y(value)!,
-            );
-          }
-        }
-      }
-
-      // draw negative bands
-      if (hasNegative) {
-        // mirror the negative bands, by flipping the canvas
-        if (mode === 'offset') {
-          context.translate(0, height);
-          context.scale(1, -1);
-        }
-
-        for (let b = 0; b < bands; b += 1) {
-          context.fillStyle = colors[bands - b - 1];
-
-          // Adjust the range based on the current band index.
-          bExtents = (b + 1 - bands) * height;
-          y.range([bands * height + bExtents, bExtents]);
-
-          // only the current data frame is being drawn i.e. what's visible:
-          for (let ii = startIndex; ii < endIndex; ii += 1) {
-            value = data[ii].y;
-            if (value >= 0) {
-              continue;
-            }
-            context.fillRect(
-              offsetX + ii * step,
-              y(-value)!,
-              step + 1,
-              y(0)! - y(-value)!,
-            );
-          }
+          context.fillRect(
+            offsetX + ii * step,
+            y(-value)!,
+            step + 1,
+            y(0)! - y(-value)!,
+          );
         }
       }
     }
-  }
-
-  render() {
-    const { className, title, width, height } = this.props;
-
-    return (
-      <div className={`horizon-row ${className}`}>
-        <span className="title">{title}</span>
-        <canvas
-          ref={c => {
-            this.canvas = c;
-          }}
-          width={width}
-          height={height}
-        />
-      </div>
-    );
-  }
+  }, [
+    rawData,
+    yDomain,
+    width,
+    height,
+    bands,
+    colors,
+    colorScale,
+    offsetX,
+    mode,
+  ]);
+
+  useEffect(() => {
+    drawChart();
+  }, [drawChart]);
+
+  return (
+    <div className={`horizon-row ${className}`}>
+      <span className="title">{title}</span>
+      <canvas ref={canvasRef} width={width} height={height} />
+    </div>
+  );
 }
 
-export default HorizonRow;
+export default memo(HorizonRow);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
index 361a27610c9..62e522f3ede 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
@@ -16,9 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
-/* eslint-disable react/forbid-prop-types, react/require-default-props */
-import { Component } from 'react';
+import { useState, useCallback, useMemo, memo } from 'react';
 import MapGL from 'react-map-gl';
 import { WebMercatorViewport } from '@math.gl/web-mercator';
 import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
@@ -63,30 +61,24 @@ interface MapBoxProps {
   bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
 }
 
-interface MapBoxState {
-  viewport: Viewport;
-}
-
-const defaultProps: Partial<MapBoxProps> = {
-  width: 400,
-  height: 400,
-  globalOpacity: 1,
-  onViewportChange: NOOP,
-  pointRadius: DEFAULT_POINT_RADIUS,
-  pointRadiusUnit: 'Pixels',
-};
-
-class MapBox extends Component<MapBoxProps, MapBoxState> {
-  static defaultProps = defaultProps;
-
-  constructor(props: MapBoxProps) {
-    super(props);
-
-    const { width = 400, height = 400, bounds } = this.props;
-    // Get a viewport that fits the given bounds, which all marks to be clustered.
-    // Derive lat, lon and zoom from this viewport. This is only done on initial
-    // render as the bounds don't update as we pan/zoom in the current design.
-
+function MapBox({
+  width = 400,
+  height = 400,
+  aggregatorName,
+  clusterer,
+  globalOpacity = 1,
+  hasCustomMetric,
+  mapStyle,
+  mapboxApiKey,
+  onViewportChange = NOOP,
+  pointRadius = DEFAULT_POINT_RADIUS,
+  pointRadiusUnit = 'Pixels',
+  renderWhileDragging,
+  rgb,
+  bounds,
+}: MapBoxProps) {
+  // Compute initial viewport from bounds
+  const initialViewport = useMemo((): Viewport => {
     let latitude = 0;
     let longitude = 0;
     let zoom = 1;
@@ -100,92 +92,72 @@ class MapBox extends Component<MapBoxProps, MapBoxState> {
       ({ latitude, longitude, zoom } = mercator);
     }
 
-    this.state = {
-      viewport: {
-        longitude,
-        latitude,
-        zoom,
-      },
-    };
-    this.handleViewportChange = this.handleViewportChange.bind(this);
-  }
-
-  handleViewportChange(viewport: Viewport) {
-    this.setState({ viewport });
-    const { onViewportChange } = this.props;
-    onViewportChange!(viewport);
-  }
-
-  render() {
-    const {
-      width,
-      height,
-      aggregatorName,
-      clusterer,
-      globalOpacity,
-      mapStyle,
-      mapboxApiKey,
-      pointRadius,
-      pointRadiusUnit,
-      renderWhileDragging,
-      rgb,
-      hasCustomMetric,
-      bounds,
-    } = this.props;
-    const { viewport } = this.state;
-    const isDragging =
-      viewport.isDragging === undefined ? false : viewport.isDragging;
-
-    // Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
-    // to an area outside of the original bounds, no additional queries are made to the backend to
-    // retrieve additional data.
-    // add this variable to widen the visible area
-    const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
-    const offsetVertical = ((height ?? 400) * 0.5) / 100;
-
-    // Guard against empty datasets where bounds may be undefined
-    const bbox =
-      bounds && bounds[0] && bounds[1]
-        ? [
-            bounds[0][0] - offsetHorizontal,
-            bounds[0][1] - offsetVertical,
-            bounds[1][0] + offsetHorizontal,
-            bounds[1][1] + offsetVertical,
-          ]
-        : [-180, -90, 180, 90]; // Default to world bounds
-
-    const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
-
-    return (
-      <MapGL
+    return { longitude, latitude, zoom };
+  }, []); // Only compute once on mount - bounds don't update as we pan/zoom
+
+  const [viewport, setViewport] = useState<Viewport>(initialViewport);
+
+  const handleViewportChange = useCallback(
+    (newViewport: Viewport) => {
+      setViewport(newViewport);
+      onViewportChange(newViewport);
+    },
+    [onViewportChange],
+  );
+
+  const isDragging =
+    viewport.isDragging === undefined ? false : viewport.isDragging;
+
+  // Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
+  // to an area outside of the original bounds, no additional queries are made to the backend to
+  // retrieve additional data.
+  // add this variable to widen the visible area
+  const offsetHorizontal = (width * 0.5) / 100;
+  const offsetVertical = (height * 0.5) / 100;
+
+  // Guard against empty datasets where bounds may be undefined
+  const bbox =
+    bounds && bounds[0] && bounds[1]
+      ? [
+          bounds[0][0] - offsetHorizontal,
+          bounds[0][1] - offsetVertical,
+          bounds[1][0] + offsetHorizontal,
+          bounds[1][1] + offsetVertical,
+        ]
+      : [-180, -90, 180, 90]; // Default to world bounds
+
+  const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
+
+  const lngLatAccessor = useCallback((location: GeoJSONLocation) => {
+    const { coordinates } = location.geometry;
+    return [coordinates[0], coordinates[1]] as [number, number];
+  }, []);
+
+  return (
+    <MapGL
+      {...viewport}
+      mapStyle={mapStyle}
+      width={width}
+      height={height}
+      mapboxApiAccessToken={mapboxApiKey}
+      onViewportChange={handleViewportChange}
+      preserveDrawingBuffer
+    >
+      <ScatterPlotGlowOverlay
         {...viewport}
-        mapStyle={mapStyle}
-        width={width}
-        height={height}
-        mapboxApiAccessToken={mapboxApiKey}
-        onViewportChange={this.handleViewportChange}
-        preserveDrawingBuffer
-      >
-        <ScatterPlotGlowOverlay
-          {...viewport}
-          isDragging={isDragging}
-          locations={clusters}
-          dotRadius={pointRadius}
-          pointRadiusUnit={pointRadiusUnit}
-          rgb={rgb}
-          globalOpacity={globalOpacity}
-          compositeOperation="screen"
-          renderWhileDragging={renderWhileDragging}
-          aggregation={hasCustomMetric ? aggregatorName : undefined}
-          lngLatAccessor={(location: GeoJSONLocation) => {
-            const { coordinates } = location.geometry;
-
-            return [coordinates[0], coordinates[1]];
-          }}
-        />
-      </MapGL>
-    );
-  }
+        isDragging={isDragging}
+        locations={clusters}
+        dotRadius={pointRadius}
+        pointRadiusUnit={pointRadiusUnit}
+        rgb={rgb}
+        globalOpacity={globalOpacity}
+        compositeOperation="screen"
+        renderWhileDragging={renderWhileDragging}
+        aggregation={hasCustomMetric ? aggregatorName : undefined}
+        lngLatAccessor={lngLatAccessor}
+      />
+    </MapGL>
+  );
 }
 
-export default MapBox;
+export default memo(MapBox);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
index c946d3f2825..37ff8bcf009 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
@@ -16,8 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-/* eslint-disable react/require-default-props */
-import { PureComponent } from 'react';
+import { memo, useCallback } from 'react';
 import { CanvasOverlay } from 'react-map-gl';
 import { kmToPixels, MILES_PER_KM } from './utils/geo';
 import roundDecimal from './utils/roundDecimal';
@@ -61,16 +60,10 @@ interface ScatterPlotGlowOverlayProps {
   isDragging?: boolean;
 }
 
-const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
-  // Same as browser default.
-  compositeOperation: 'source-over',
-  dotRadius: 4,
-  lngLatAccessor: (location: GeoJSONLocation) => [
-    location.geometry.coordinates[0],
-    location.geometry.coordinates[1],
-  ],
-  renderWhileDragging: true,
-};
+const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
+  location.geometry.coordinates[0],
+  location.geometry.coordinates[1],
+];
 
 const computeClusterLabel = (
   properties: Record<string, number | string | boolean | null | undefined>,
@@ -101,300 +94,304 @@ const computeClusterLabel = (
   return count;
 };
 
-class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
-  static defaultProps = defaultProps;
-
-  constructor(props: ScatterPlotGlowOverlayProps) {
-    super(props);
-    this.redraw = this.redraw.bind(this);
-  }
-
-  drawText(
-    ctx: CanvasRenderingContext2D,
-    pixel: [number, number],
-    options: DrawTextOptions = {},
-  ) {
-    const IS_DARK_THRESHOLD = 110;
-    const {
-      fontHeight = 0,
-      label = '',
-      radius = 0,
-      rgb = [0, 0, 0],
-      shadow = false,
-    } = options;
-    const maxWidth = radius * 1.8;
-    const luminance = luminanceFromRGB(
-      rgb[1] as number,
-      rgb[2] as number,
-      rgb[3] as number,
-    );
-
-    ctx.globalCompositeOperation = 'source-over';
-    ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
-    ctx.font = `${fontHeight}px sans-serif`;
-    ctx.textAlign = 'center';
-    ctx.textBaseline = 'middle';
-    if (shadow) {
-      ctx.shadowBlur = 15;
-      ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
-    }
-
-    const textWidth = ctx.measureText(String(label)).width;
-    if (textWidth > maxWidth) {
-      const scale = fontHeight / textWidth;
-      ctx.font = `${scale * maxWidth}px sans-serif`;
-    }
-
-    const { compositeOperation } = this.props;
-
-    ctx.fillText(String(label), pixel[0], pixel[1]);
-    ctx.globalCompositeOperation = (compositeOperation ??
-      'source-over') as GlobalCompositeOperation;
-    ctx.shadowBlur = 0;
-    ctx.shadowColor = '';
-  }
-
-  // Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
-  redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
-    const {
-      aggregation,
-      compositeOperation,
-      dotRadius,
-      globalOpacity,
-      lngLatAccessor,
-      locations,
-      pointRadiusUnit,
-      renderWhileDragging,
-      rgb,
-      zoom,
-    } = this.props;
-
-    const radius = dotRadius ?? 4;
-    const clusterLabelMap: (number | string)[] = [];
-
-    locations.forEach((location, i) => {
-      if (location.properties.cluster) {
-        clusterLabelMap[i] = computeClusterLabel(
-          location.properties,
-          aggregation,
-        );
+function ScatterPlotGlowOverlay({
+  aggregation,
+  compositeOperation = 'source-over',
+  dotRadius = 4,
+  globalOpacity,
+  lngLatAccessor = defaultLngLatAccessor,
+  locations,
+  pointRadiusUnit,
+  renderWhileDragging = true,
+  rgb,
+  zoom,
+}: ScatterPlotGlowOverlayProps) {
+  const drawText = useCallback(
+    (
+      ctx: CanvasRenderingContext2D,
+      pixel: [number, number],
+      options: DrawTextOptions = {},
+    ) => {
+      const IS_DARK_THRESHOLD = 110;
+      const {
+        fontHeight = 0,
+        label = '',
+        radius = 0,
+        rgb: rgbOption = [0, 0, 0],
+        shadow = false,
+      } = options;
+      const maxWidth = radius * 1.8;
+      const luminance = luminanceFromRGB(
+        rgbOption[1] as number,
+        rgbOption[2] as number,
+        rgbOption[3] as number,
+      );
+
+      ctx.globalCompositeOperation = 'source-over';
+      ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
+      ctx.font = `${fontHeight}px sans-serif`;
+      ctx.textAlign = 'center';
+      ctx.textBaseline = 'middle';
+      if (shadow) {
+        ctx.shadowBlur = 15;
+        ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
       }
-    });
 
-    const filteredLabels = clusterLabelMap.filter(
-      v => !Number.isNaN(v),
-    ) as number[];
-    // Guard against empty array or zero max to prevent NaN from division
-    const maxLabel =
-      filteredLabels.length > 0 ? Math.max(...filteredLabels) : 1;
-    const safeMaxLabel = maxLabel > 0 ? maxLabel : 1;
+      const textWidth = ctx.measureText(String(label)).width;
+      if (textWidth > maxWidth) {
+        const scale = fontHeight / textWidth;
+        ctx.font = `${scale * maxWidth}px sans-serif`;
+      }
 
-    // Calculate min/max radius values for Pixels mode scaling
-    let minRadiusValue = Infinity;
-    let maxRadiusValue = -Infinity;
-    if (pointRadiusUnit === 'Pixels') {
-      locations.forEach(location => {
-        // Accept both null and undefined as "no value" and coerce potential numeric strings
-        if (
-          !location.properties.cluster &&
-          location.properties.radius != null
-        ) {
-          const radiusValueRaw = location.properties.radius;
-          const radiusValue = Number(radiusValueRaw);
-          if (Number.isFinite(radiusValue)) {
-            minRadiusValue = Math.min(minRadiusValue, radiusValue);
-            maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
-          }
+      ctx.fillText(String(label), pixel[0], pixel[1]);
+      ctx.globalCompositeOperation = (compositeOperation ??
+        'source-over') as GlobalCompositeOperation;
+      ctx.shadowBlur = 0;
+      ctx.shadowColor = '';
+    },
+    [compositeOperation],
+  );
+
+  const redraw = useCallback(
+    ({ width, height, ctx, isDragging, project }: RedrawParams) => {
+      const radius = dotRadius ?? 4;
+      const clusterLabelMap: (number | string)[] = [];
+
+      locations.forEach((location, i) => {
+        if (location.properties.cluster) {
+          clusterLabelMap[i] = computeClusterLabel(
+            location.properties,
+            aggregation,
+          );
         }
       });
-    }
-
-    ctx.clearRect(0, 0, width, height);
-    ctx.globalCompositeOperation = (compositeOperation ??
-      'source-over') as GlobalCompositeOperation;
-
-    if ((renderWhileDragging || !isDragging) && locations) {
-      locations.forEach(function _forEach(
-        this: ScatterPlotGlowOverlay,
-        location: GeoJSONLocation,
-        i: number,
-      ) {
-        const pixel = project(lngLatAccessor!(location)) as [number, number];
-        const pixelRounded: [number, number] = [
-          roundDecimal(pixel[0], 1),
-          roundDecimal(pixel[1], 1),
-        ];
 
-        if (
-          pixelRounded[0] + radius >= 0 &&
-          pixelRounded[0] - radius < width &&
-          pixelRounded[1] + radius >= 0 &&
-          pixelRounded[1] - radius < height
-        ) {
-          ctx.beginPath();
-          if (location.properties.cluster) {
-            let clusterLabel = clusterLabelMap[i];
-            // Validate clusterLabel is a finite number before using it for radius calculation
-            const numericLabel = Number(clusterLabel);
-            const safeNumericLabel = Number.isFinite(numericLabel)
-              ? numericLabel
-              : 0;
-            const scaledRadius = roundDecimal(
-              (safeNumericLabel / safeMaxLabel) ** 0.5 * radius,
-              1,
-            );
-            const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
-            const [x, y] = pixelRounded;
-            const gradient = ctx.createRadialGradient(
-              x,
-              y,
-              scaledRadius,
-              x,
-              y,
-              0,
-            );
-
-            gradient.addColorStop(
-              1,
-              `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
-            );
-            gradient.addColorStop(
-              0,
-              `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
-            );
-            ctx.arc(
-              pixelRounded[0],
-              pixelRounded[1],
-              scaledRadius,
-              0,
-              Math.PI * 2,
-            );
-            ctx.fillStyle = gradient;
-            ctx.fill();
-
-            if (Number.isFinite(safeNumericLabel)) {
-              let label: string | number = clusterLabel;
-              if (safeNumericLabel >= 10000) {
-                label = `${Math.round(safeNumericLabel / 1000)}k`;
-              } else if (safeNumericLabel >= 1000) {
-                label = `${Math.round(safeNumericLabel / 100) / 10}k`;
-              }
-              this.drawText(ctx, pixelRounded, {
-                fontHeight,
-                label,
-                radius: scaledRadius,
-                rgb,
-                shadow: true,
-              });
+      const filteredLabels = clusterLabelMap.filter(
+        v => !Number.isNaN(v),
+      ) as number[];
+      // Guard against empty array or zero max to prevent NaN from division
+      const maxLabel =
+        filteredLabels.length > 0 ? Math.max(...filteredLabels) : 1;
+      const safeMaxLabel = maxLabel > 0 ? maxLabel : 1;
+
+      // Calculate min/max radius values for Pixels mode scaling
+      let minRadiusValue = Infinity;
+      let maxRadiusValue = -Infinity;
+      if (pointRadiusUnit === 'Pixels') {
+        locations.forEach(location => {
+          // Accept both null and undefined as "no value" and coerce potential numeric strings
+          if (
+            !location.properties.cluster &&
+            location.properties.radius != null
+          ) {
+            const radiusValueRaw = location.properties.radius;
+            const radiusValue = Number(radiusValueRaw);
+            if (Number.isFinite(radiusValue)) {
+              minRadiusValue = Math.min(minRadiusValue, radiusValue);
+              maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
             }
-          } else {
-            const defaultRadius = radius / 6;
-            const rawRadius = location.properties.radius;
-            const radiusProperty =
-              typeof rawRadius === 'number' ? rawRadius : null;
-            const pointMetric = location.properties.metric ?? null;
-            let pointRadius: number = radiusProperty ?? defaultRadius;
-            let pointLabel: string | number | undefined;
-
-            if (radiusProperty != null) {
-              const pointLatitude = lngLatAccessor!(location)[1];
-              if (pointRadiusUnit === 'Kilometers') {
-                pointLabel = `${roundDecimal(pointRadius, 2)}km`;
-                pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
-              } else if (pointRadiusUnit === 'Miles') {
-                pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
-                pointRadius = kmToPixels(
-                  pointRadius * MILES_PER_KM,
-                  pointLatitude,
-                  zoom ?? 0,
-                );
-              } else if (pointRadiusUnit === 'Pixels') {
-                // Scale pixel values to a reasonable range (radius/6 to radius/3)
-                // This ensures points are visible and proportional to their values
-                const MIN_POINT_RADIUS = radius / 6;
-                const MAX_POINT_RADIUS = radius / 3;
+          }
+        });
+      }
 
-                if (
-                  Number.isFinite(minRadiusValue) &&
-                  Number.isFinite(maxRadiusValue) &&
-                  maxRadiusValue > minRadiusValue
-                ) {
-                  // Normalize the value to 0-1 range, then scale to pixel range
-                  const numericPointRadius = Number(pointRadius);
-                  if (!Number.isFinite(numericPointRadius)) {
-                    // fallback to minimum visible size when the value is not a finite number
-                    pointRadius = MIN_POINT_RADIUS;
+      ctx.clearRect(0, 0, width, height);
+      ctx.globalCompositeOperation = (compositeOperation ??
+        'source-over') as GlobalCompositeOperation;
+
+      if ((renderWhileDragging || !isDragging) && locations) {
+        locations.forEach((location: GeoJSONLocation, i: number) => {
+          const pixel = project(lngLatAccessor(location)) as [number, number];
+          const pixelRounded: [number, number] = [
+            roundDecimal(pixel[0], 1),
+            roundDecimal(pixel[1], 1),
+          ];
+
+          if (
+            pixelRounded[0] + radius >= 0 &&
+            pixelRounded[0] - radius < width &&
+            pixelRounded[1] + radius >= 0 &&
+            pixelRounded[1] - radius < height
+          ) {
+            ctx.beginPath();
+            if (location.properties.cluster) {
+              const clusterLabel = clusterLabelMap[i];
+              // Validate clusterLabel is a finite number before using it for radius calculation
+              const numericLabel = Number(clusterLabel);
+              const safeNumericLabel = Number.isFinite(numericLabel)
+                ? numericLabel
+                : 0;
+              const scaledRadius = roundDecimal(
+                (safeNumericLabel / safeMaxLabel) ** 0.5 * radius,
+                1,
+              );
+              const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
+              const [x, y] = pixelRounded;
+              const gradient = ctx.createRadialGradient(
+                x,
+                y,
+                scaledRadius,
+                x,
+                y,
+                0,
+              );
+
+              gradient.addColorStop(
+                1,
+                `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
+              );
+              gradient.addColorStop(
+                0,
+                `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
+              );
+              ctx.arc(
+                pixelRounded[0],
+                pixelRounded[1],
+                scaledRadius,
+                0,
+                Math.PI * 2,
+              );
+              ctx.fillStyle = gradient;
+              ctx.fill();
+
+              if (Number.isFinite(safeNumericLabel)) {
+                let label: string | number = clusterLabel;
+                if (safeNumericLabel >= 10000) {
+                  label = `${Math.round(safeNumericLabel / 1000)}k`;
+                } else if (safeNumericLabel >= 1000) {
+                  label = `${Math.round(safeNumericLabel / 100) / 10}k`;
+                }
+                drawText(ctx, pixelRounded, {
+                  fontHeight,
+                  label,
+                  radius: scaledRadius,
+                  rgb,
+                  shadow: true,
+                });
+              }
+            } else {
+              const defaultRadius = radius / 6;
+              const rawRadius = location.properties.radius;
+              const radiusProperty =
+                typeof rawRadius === 'number' ? rawRadius : null;
+              const pointMetric = location.properties.metric ?? null;
+              let pointRadius: number = radiusProperty ?? defaultRadius;
+              let pointLabel: string | number | undefined;
+
+              if (radiusProperty != null) {
+                const pointLatitude = lngLatAccessor(location)[1];
+                if (pointRadiusUnit === 'Kilometers') {
+                  pointLabel = `${roundDecimal(pointRadius, 2)}km`;
+                  pointRadius = kmToPixels(
+                    pointRadius,
+                    pointLatitude,
+                    zoom ?? 0,
+                  );
+                } else if (pointRadiusUnit === 'Miles') {
+                  pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
+                  pointRadius = kmToPixels(
+                    pointRadius * MILES_PER_KM,
+                    pointLatitude,
+                    zoom ?? 0,
+                  );
+                } else if (pointRadiusUnit === 'Pixels') {
+                  // Scale pixel values to a reasonable range (radius/6 to radius/3)
+                  // This ensures points are visible and proportional to their values
+                  const MIN_POINT_RADIUS = radius / 6;
+                  const MAX_POINT_RADIUS = radius / 3;
+
+                  if (
+                    Number.isFinite(minRadiusValue) &&
+                    Number.isFinite(maxRadiusValue) &&
+                    maxRadiusValue > minRadiusValue
+                  ) {
+                    // Normalize the value to 0-1 range, then scale to pixel range
+                    const numericPointRadius = Number(pointRadius);
+                    if (!Number.isFinite(numericPointRadius)) {
+                      // fallback to minimum visible size when the value is not a finite number
+                      pointRadius = MIN_POINT_RADIUS;
+                    } else {
+                      const normalizedValueRaw =
+                        (numericPointRadius - minRadiusValue) /
+                        (maxRadiusValue - minRadiusValue);
+                      const normalizedValue = Math.max(
+                        0,
+                        Math.min(1, normalizedValueRaw),
+                      );
+                      pointRadius =
+                        MIN_POINT_RADIUS +
+                        normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
+                    }
+                    pointLabel = `${roundDecimal(radiusProperty, 2)}`;
+                  } else if (
+                    Number.isFinite(minRadiusValue) &&
+                    minRadiusValue === maxRadiusValue
+                  ) {
+                    // All values are the same, use a fixed medium size
+                    pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
+                    pointLabel = `${roundDecimal(radiusProperty, 2)}`;
                   } else {
-                    const normalizedValueRaw =
-                      (numericPointRadius - minRadiusValue) /
-                      (maxRadiusValue - minRadiusValue);
-                    const normalizedValue = Math.max(
-                      0,
-                      Math.min(1, normalizedValueRaw),
+                    // Use raw pixel values if they're already in a reasonable range
+                    pointRadius = Math.max(
+                      MIN_POINT_RADIUS,
+                      Math.min(pointRadius, MAX_POINT_RADIUS),
                     );
-                    pointRadius =
-                      MIN_POINT_RADIUS +
-                      normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
+                    pointLabel = `${roundDecimal(radiusProperty, 2)}`;
                   }
-                  pointLabel = `${roundDecimal(radiusProperty, 2)}`;
-                } else if (
-                  Number.isFinite(minRadiusValue) &&
-                  minRadiusValue === maxRadiusValue
-                ) {
-                  // All values are the same, use a fixed medium size
-                  pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
-                  pointLabel = `${roundDecimal(radiusProperty, 2)}`;
-                } else {
-                  // Use raw pixel values if they're already in a reasonable range
-                  pointRadius = Math.max(
-                    MIN_POINT_RADIUS,
-                    Math.min(pointRadius, MAX_POINT_RADIUS),
-                  );
-                  pointLabel = `${roundDecimal(radiusProperty, 2)}`;
                 }
               }
-            }
-
-            if (pointMetric !== null) {
-              const numericMetric = parseFloat(String(pointMetric));
-              pointLabel = Number.isFinite(numericMetric)
-                ? roundDecimal(numericMetric, 2)
-                : String(pointMetric);
-            }
 
-            // Fall back to default points if pointRadius wasn't a numerical column
-            if (!pointRadius) {
-              pointRadius = defaultRadius;
-            }
+              if (pointMetric !== null) {
+                const numericMetric = parseFloat(String(pointMetric));
+                pointLabel = Number.isFinite(numericMetric)
+                  ? roundDecimal(numericMetric, 2)
+                  : String(pointMetric);
+              }
 
-            ctx.arc(
-              pixelRounded[0],
-              pixelRounded[1],
-              roundDecimal(pointRadius, 1),
-              0,
-              Math.PI * 2,
-            );
-            ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
-            ctx.fill();
+              // Fall back to default points if pointRadius wasn't a numerical column
+              if (!pointRadius) {
+                pointRadius = defaultRadius;
+              }
 
-            if (pointLabel !== undefined) {
-              this.drawText(ctx, pixelRounded, {
-                fontHeight: roundDecimal(pointRadius, 1),
-                label: pointLabel,
-                radius: pointRadius,
-                rgb,
-                shadow: false,
-              });
+              ctx.arc(
+                pixelRounded[0],
+                pixelRounded[1],
+                roundDecimal(pointRadius, 1),
+                0,
+                Math.PI * 2,
+              );
+              ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
+              ctx.fill();
+
+              if (pointLabel !== undefined) {
+                drawText(ctx, pixelRounded, {
+                  fontHeight: roundDecimal(pointRadius, 1),
+                  label: pointLabel,
+                  radius: pointRadius,
+                  rgb,
+                  shadow: false,
+                });
+              }
             }
           }
-        }
-      }, this);
-    }
-  }
+        });
+      }
+    },
+    [
+      aggregation,
+      compositeOperation,
+      dotRadius,
+      drawText,
+      globalOpacity,
+      lngLatAccessor,
+      locations,
+      pointRadiusUnit,
+      renderWhileDragging,
+      rgb,
+      zoom,
+    ],
+  );
 
-  render() {
-    return <CanvasOverlay redraw={this.redraw} />;
-  }
+  return <CanvasOverlay redraw={redraw} />;
 }
 
-export default ScatterPlotGlowOverlay;
+export default memo(ScatterPlotGlowOverlay);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
index 65f94ea66b7..8c2aa161750 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
@@ -17,27 +17,19 @@
  * under the License.
  */
 /* eslint-disable react/no-array-index-key */
-import { PureComponent } from 'react';
 import { styled } from '@apache-superset/core/ui';
 import TTestTable, { DataEntry } from './TTestTable';
 
 interface PairedTTestProps {
-  alpha: number;
-  className: string;
+  alpha?: number;
+  className?: string;
   data: Record<string, DataEntry[]>;
   groups: string[];
-  liftValPrec: number;
+  liftValPrec?: number;
   metrics: string[];
-  pValPrec: number;
+  pValPrec?: number;
 }
 
-const defaultProps = {
-  alpha: 0.05,
-  className: '',
-  liftValPrec: 4,
-  pValPrec: 6,
-};
-
 const StyledDiv = styled.div`
   ${({ theme }) => `
     .superset-legacy-chart-paired_ttest .scrollbar-container {
@@ -114,35 +106,36 @@ const StyledDiv = styled.div`
   `}
 `;
 
-class PairedTTest extends PureComponent<PairedTTestProps> {
-  static defaultProps = defaultProps;
-
-  render() {
-    const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } =
-      this.props;
-
-    return (
-      <StyledDiv>
-        <div className={`superset-legacy-chart-paired-t-test ${className}`}>
-          <div className="paired-ttest-table">
-            <div className="scrollbar-content">
-              {metrics.map((metric, i) => (
-                <TTestTable
-                  key={i}
-                  metric={metric}
-                  groups={groups}
-                  data={data[metric]}
-                  alpha={alpha}
-                  pValPrec={Math.min(pValPrec, 32)}
-                  liftValPrec={Math.min(liftValPrec, 32)}
-                />
-              ))}
-            </div>
+function PairedTTest({
+  alpha = 0.05,
+  className = '',
+  data,
+  groups,
+  liftValPrec = 4,
+  metrics,
+  pValPrec = 6,
+}: PairedTTestProps) {
+  return (
+    <StyledDiv>
+      <div className={`superset-legacy-chart-paired-t-test ${className}`}>
+        <div className="paired-ttest-table">
+          <div className="scrollbar-content">
+            {metrics.map((metric, i) => (
+              <TTestTable
+                key={i}
+                metric={metric}
+                groups={groups}
+                data={data[metric]}
+                alpha={alpha}
+                pValPrec={Math.min(pValPrec, 32)}
+                liftValPrec={Math.min(liftValPrec, 32)}
+              />
+            ))}
           </div>
         </div>
-      </StyledDiv>
-    );
-  }
+      </div>
+    </StyledDiv>
+  );
 }
 
 export default PairedTTest;
diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
index abc2a2ccff2..36210009277 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
@@ -18,7 +18,7 @@
  */
 /* eslint-disable react/no-array-index-key, react/jsx-no-bind */
 import dist from 'distributions';
-import { Component } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
 import { Table, Tr, Td, Thead, Th } from 'reactable';
 
 interface DataPointValue {
@@ -32,279 +32,295 @@ export interface DataEntry {
 }
 
 interface TTestTableProps {
-  alpha: number;
+  alpha?: number;
   data: DataEntry[];
   groups: string[];
-  liftValPrec: number;
+  liftValPrec?: number;
   metric: string;
-  pValPrec: number;
+  pValPrec?: number;
 }
 
-interface TTestTableState {
-  control: number;
-  liftValues: (string | number)[];
-  pValues: (string | number)[];
-}
-
-const defaultProps = {
-  alpha: 0.05,
-  liftValPrec: 4,
-  pValPrec: 6,
-};
+function TTestTable({
+  alpha = 0.05,
+  data,
+  groups,
+  liftValPrec = 4,
+  metric,
+  pValPrec = 6,
+}: TTestTableProps) {
+  const [control, setControl] = useState(0);
+  const [liftValues, setLiftValues] = useState<(string | number)[]>([]);
+  const [pValues, setPValues] = useState<(string | number)[]>([]);
 
-class TTestTable extends Component<TTestTableProps, TTestTableState> {
-  static defaultProps = defaultProps;
+  const computeLift = useCallback(
+    (values: DataPointValue[], controlValues: DataPointValue[]): string => {
+      // Compute the lift value between two time series
+      let sumValues = 0;
+      let sumControl = 0;
+      values.forEach((value, i) => {
+        sumValues += value.y;
+        sumControl += controlValues[i].y;
+      });
 
-  constructor(props: TTestTableProps) {
-    super(props);
-    this.state = {
-      control: 0,
-      liftValues: [],
-      pValues: [],
-    };
-  }
+      return (((sumValues - sumControl) / sumControl) * 100).toFixed(
+        liftValPrec,
+      );
+    },
+    [liftValPrec],
+  );
 
-  componentDidMount() {
-    const { control } = this.state;
-    this.computeTTest(control); // initially populate table
-  }
+  const computePValue = useCallback(
+    (
+      values: DataPointValue[],
+      controlValues: DataPointValue[],
+    ): string | number => {
+      // Compute the p-value from Student's t-test
+      // between two time series
+      let diffSum = 0;
+      let diffSqSum = 0;
+      let finiteCount = 0;
+      values.forEach((value, i) => {
+        const diff = controlValues[i].y - value.y;
+        /* eslint-disable-next-line */
+        if (isFinite(diff)) {
+          finiteCount += 1;
+          diffSum += diff;
+          diffSqSum += diff * diff;
+        }
+      });
+      const tvalue = -Math.abs(
+        diffSum *
+          Math.sqrt(
+            (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
+          ),
+      );
+      try {
+        return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
+          pValPrec,
+        ); // two-sided test
+      } catch (error) {
+        return NaN;
+      }
+    },
+    [pValPrec],
+  );
 
-  getLiftStatus(row: number): string {
-    const { control, liftValues } = this.state;
-    // Get a css class name for coloring
-    if (row === control) {
-      return 'control';
-    }
-    const liftVal = liftValues[row];
-    if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
-      return 'invalid'; // infinite or NaN values
-    }
+  const computeTTest = useCallback(
+    (controlIndex: number) => {
+      // Compute lift and p-values for each row
+      // against the selected control
+      const newPValues: (string | number)[] = [];
+      const newLiftValues: (string | number)[] = [];
+      if (!data) {
+        return;
+      }
+      for (let i = 0; i < data.length; i += 1) {
+        if (i === controlIndex) {
+          newPValues.push('control');
+          newLiftValues.push('control');
+        } else {
+          newPValues.push(
+            computePValue(data[i].values, data[controlIndex].values),
+          );
+          newLiftValues.push(
+            computeLift(data[i].values, data[controlIndex].values),
+          );
+        }
+      }
+      setControl(controlIndex);
+      setLiftValues(newLiftValues);
+      setPValues(newPValues);
+    },
+    [data, computeLift, computePValue],
+  );
 
-    return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false
-  }
+  // Initially populate table on mount
+  useEffect(() => {
+    computeTTest(control);
+  }, [computeTTest, control]);
 
-  getPValueStatus(row: number): string {
-    const { control, pValues } = this.state;
-    if (row === control) {
-      return 'control';
-    }
-    const pVal = pValues[row];
-    if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
-      return 'invalid';
-    }
+  const getLiftStatus = useCallback(
+    (row: number): string => {
+      // Get a css class name for coloring
+      if (row === control) {
+        return 'control';
+      }
+      const liftVal = liftValues[row];
+      if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
+        return 'invalid'; // infinite or NaN values
+      }
 
-    return ''; // p-values won't normally be colored
-  }
+      return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false
+    },
+    [control, liftValues],
+  );
 
-  getSignificance(row: number): string | boolean {
-    const { control, pValues } = this.state;
-    const { alpha } = this.props;
-    // Color significant as green, else red
-    if (row === control) {
-      return 'control';
-    }
+  const getPValueStatus = useCallback(
+    (row: number): string => {
+      if (row === control) {
+        return 'control';
+      }
+      const pVal = pValues[row];
+      if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
+        return 'invalid';
+      }
 
-    // p-values significant below set threshold
-    return Number(pValues[row]) <= alpha;
-  }
+      return ''; // p-values won't normally be colored
+    },
+    [control, pValues],
+  );
 
-  computeLift(values: DataPointValue[], control: DataPointValue[]): string {
-    const { liftValPrec } = this.props;
-    // Compute the lift value between two time series
-    let sumValues = 0;
-    let sumControl = 0;
-    values.forEach((value, i) => {
-      sumValues += value.y;
-      sumControl += control[i].y;
-    });
+  const getSignificance = useCallback(
+    (row: number): string | boolean => {
+      // Color significant as green, else red
+      if (row === control) {
+        return 'control';
+      }
 
-    return (((sumValues - sumControl) / sumControl) * 100).toFixed(liftValPrec);
-  }
+      // p-values significant below set threshold
+      return Number(pValues[row]) <= alpha;
+    },
+    [control, pValues, alpha],
+  );
 
-  computePValue(
-    values: DataPointValue[],
-    control: DataPointValue[],
-  ): string | number {
-    const { pValPrec } = this.props;
-    // Compute the p-value from Student's t-test
-    // between two time series
-    let diffSum = 0;
-    let diffSqSum = 0;
-    let finiteCount = 0;
-    values.forEach((value, i) => {
-      const diff = control[i].y - value.y;
-      /* eslint-disable-next-line */
-      if (isFinite(diff)) {
-        finiteCount += 1;
-        diffSum += diff;
-        diffSqSum += diff * diff;
-      }
-    });
-    const tvalue = -Math.abs(
-      diffSum *
-        Math.sqrt(
-          (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
-        ),
-    );
-    try {
-      return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
-        pValPrec,
-      ); // two-sided test
-    } catch (error) {
-      return NaN;
-    }
-  }
+  const handleRowClick = useCallback(
+    (rowIndex: number) => {
+      computeTTest(rowIndex);
+    },
+    [computeTTest],
+  );
 
-  computeTTest(control: number) {
-    // Compute lift and p-values for each row
-    // against the selected control
-    const { data } = this.props;
-    const pValues: (string | number)[] = [];
-    const liftValues: (string | number)[] = [];
-    if (!data) {
-      return;
-    }
-    for (let i = 0; i < data.length; i += 1) {
-      if (i === control) {
-        pValues.push('control');
-        liftValues.push('control');
-      } else {
-        pValues.push(this.computePValue(data[i].values, data[control].values));
-        liftValues.push(this.computeLift(data[i].values, data[control].values));
-      }
-    }
-    this.setState({ control, liftValues, pValues });
+  if (!Array.isArray(groups) || groups.length === 0) {
+    throw Error('Group by param is required');
   }
 
-  render() {
-    const { data, metric, groups } = this.props;
-    const { control, liftValues, pValues } = this.state;
-
-    if (!Array.isArray(groups) || groups.length === 0) {
-      throw Error('Group by param is required');
-    }
+  // Render column header for each group
+  const columns = groups.map((group, i) => (
+    <Th key={i} column={group}>
+      {group}
+    </Th>
+  ));
+  const numGroups = groups.length;
+  // Columns for p-value, lift-value, and significance (true/false)
+  columns.push(
+    <Th key={numGroups + 1} column="pValue">
+      p-value
+    </Th>,
+  );
+  columns.push(
+    <Th key={numGroups + 2} column="liftValue">
+      Lift %
+    </Th>,
+  );
+  columns.push(
+    <Th key={numGroups + 3} column="significant">
+      Significant
+    </Th>,
+  );
 
-    // Render column header for each group
-    const columns = groups.map((group, i) => (
-      <Th key={i} column={group}>
-        {group}
-      </Th>
-    ));
-    const numGroups = groups.length;
-    // Columns for p-value, lift-value, and significance (true/false)
-    columns.push(
-      <Th key={numGroups + 1} column="pValue">
-        p-value
-      </Th>,
+  const rows = data.map((entry, i) => {
+    const values = groups.map(
+      (
+        group,
+        j, // group names
+      ) => <Td key={j} column={group} data={entry.group[j]} />,
     );
-    columns.push(
-      <Th key={numGroups + 2} column="liftValue">
-        Lift %
-      </Th>,
+    values.push(
+      <Td
+        key={numGroups + 1}
+        className={getPValueStatus(i)}
+        column="pValue"
+        data={pValues[i]}
+      />,
     );
-    columns.push(
-      <Th key={numGroups + 3} column="significant">
-        Significant
-      </Th>,
+    values.push(
+      <Td
+        key={numGroups + 2}
+        className={getLiftStatus(i)}
+        column="liftValue"
+        data={liftValues[i]}
+      />,
+    );
+    values.push(
+      <Td
+        key={numGroups + 3}
+        className={getSignificance(i).toString()}
+        column="significant"
+        data={getSignificance(i)}
+      />,
     );
-    const rows = data.map((entry, i) => {
-      const values = groups.map(
-        (
-          group,
-          j, // group names
-        ) => <Td key={j} column={group} data={entry.group[j]} />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 1}
-          className={this.getPValueStatus(i)}
-          column="pValue"
-          data={pValues[i]}
-        />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 2}
-          className={this.getLiftStatus(i)}
-          column="liftValue"
-          data={liftValues[i]}
-        />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 3}
-          className={this.getSignificance(i).toString()}
-          column="significant"
-          data={this.getSignificance(i)}
-        />,
-      );
 
-      return (
-        <Tr
-          key={i}
-          className={i === control ? 'control' : ''}
-          onClick={this.computeTTest.bind(this, i)}
-        >
-          {values}
-        </Tr>
-      );
-    });
-    // When sorted ascending, 'control' will always be at top
-    type SortConfigItem =
-      | string
-      | { column: string; sortFunction: (a: string, b: string) => number };
-    const sortConfig: SortConfigItem[] = (groups as SortConfigItem[]).concat([
-      {
-        column: 'pValue',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+    return (
+      <Tr
+        key={i}
+        className={i === control ? 'control' : ''}
+        onClick={() => handleRowClick(i)}
+      >
+        {values}
+      </Tr>
+    );
+  });
+
+  // When sorted ascending, 'control' will always be at top
+  type SortConfigItem =
+    | string
+    | { column: string; sortFunction: (a: string, b: string) => number };
 
-          return a > b ? 1 : -1; // p-values ascending
+  const sortConfig: SortConfigItem[] = useMemo(
+    () =>
+      (groups as SortConfigItem[]).concat([
+        {
+          column: 'pValue',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
+
+            return a > b ? 1 : -1; // p-values ascending
+          },
         },
-      },
-      {
-        column: 'liftValue',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+        {
+          column: 'liftValue',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
 
-          return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
+            return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
+          },
         },
-      },
-      {
-        column: 'significant',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+        {
+          column: 'significant',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
 
-          return a > b ? -1 : 1; // significant values first
+            return a > b ? -1 : 1; // significant values first
+          },
         },
-      },
-    ]);
+      ]),
+    [groups],
+  );
 
-    return (
-      <div>
-        <h3>{metric}</h3>
-        <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
-          <Thead>{columns}</Thead>
-          {rows}
-        </Table>
-      </div>
-    );
-  }
+  return (
+    <div>
+      <h3>{metric}</h3>
+      <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
+        <Thead>{columns}</Thead>
+        {rows}
+      </Table>
+    </div>
+  );
 }
 
 export default TTestTable;
diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
index 9e5565b9f26..1a6716294ae 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
@@ -17,16 +17,14 @@
  * under the License.
  */
 
-import { PureComponent } from 'react';
+import { memo } from 'react';
 import { TableRenderer } from './TableRenderers';
 import type { ComponentProps } from 'react';
 
 type PivotTableProps = ComponentProps<typeof TableRenderer>;
 
-class PivotTable extends PureComponent<PivotTableProps> {
-  render() {
-    return <TableRenderer {...this.props} />;
-  }
+function PivotTable(props: PivotTableProps) {
+  return <TableRenderer {...props} />;
 }
 
-export default PivotTable;
+export default memo(PivotTable);
diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
index 6e536641c39..c786dda7502 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
@@ -17,7 +17,15 @@
  * under the License.
  */
 
-import { Component, ReactNode, MouseEvent } from 'react';
+import {
+  ReactNode,
+  MouseEvent,
+  useState,
+  useCallback,
+  useRef,
+  useMemo,
+  useEffect,
+} from 'react';
 import { safeHtmlSpan } from '@superset-ui/core';
 import { t } from '@apache-superset/core/ui';
 import PropTypes from 'prop-types';
@@ -80,7 +88,7 @@ interface TableRendererProps {
   cols: string[];
   rows: string[];
   aggregatorName: string;
-  tableOptions: TableOptions;
+  tableOptions?: TableOptions;
   subtotalOptions?: SubtotalOptions;
   namesMapping?: Record<string, string>;
   onContextMenu: (
@@ -93,13 +101,6 @@ interface TableRendererProps {
   [key: string]: unknown;
 }
 
-interface TableRendererState {
-  collapsedRows: Record<string, boolean>;
-  collapsedCols: Record<string, boolean>;
-  sortingOrder: string[];
-  activeSortColumn?: number | null;
-}
-
 interface PivotSettings {
   pivotData: InstanceType<typeof PivotData>;
   colAttrs: string[];
@@ -272,68 +273,318 @@ function convertToArray(
   return result;
 }
 
-export class TableRenderer extends Component<
-  TableRendererProps,
-  TableRendererState
-> {
-  sortCache: Map<string, string[][]>;
-  cachedProps: TableRendererProps | null;
-  cachedBasePivotSettings: PivotSettings | null;
-
-  static propTypes: Record<string, unknown>;
-  static defaultProps: Record<string, unknown>;
-
-  constructor(props: TableRendererProps) {
-    super(props);
-
-    // We need state to record which entries are collapsed and which aren't.
-    // This is an object with flat-keys indicating if the corresponding rows
-    // should be collapsed.
-    this.state = { collapsedRows: {}, collapsedCols: {}, sortingOrder: [] };
-    this.sortCache = new Map();
-    this.cachedProps = null;
-    this.cachedBasePivotSettings = null;
-    this.clickHeaderHandler = this.clickHeaderHandler.bind(this);
-    this.clickHandler = this.clickHandler.bind(this);
-  }
+export function TableRenderer({
+  cols,
+  rows,
+  aggregatorName,
+  tableOptions = {},
+  subtotalOptions,
+  namesMapping: namesMappingProp,
+  onContextMenu,
+  allowRenderHtml,
+  ...restProps
+}: TableRendererProps) {
+  const [collapsedRows, setCollapsedRows] = useState<Record<string, boolean>>(
+    {},
+  );
+  const [collapsedCols, setCollapsedCols] = useState<Record<string, boolean>>(
+    {},
+  );
+  const [sortingOrder, setSortingOrder] = useState<string[]>([]);
+  const [activeSortColumn, setActiveSortColumn] = useState<number | null>(null);
+  const [sortedRowKeys, setSortedRowKeys] = useState<string[][] | null>(null);
+
+  const sortCacheRef = useRef(new Map<string, string[][]>());
+
+  // Memoize props object to maintain referential stability
+  const props = useMemo<TableRendererProps>(
+    () => ({
+      cols,
+      rows,
+      aggregatorName,
+      tableOptions,
+      subtotalOptions,
+      namesMapping: namesMappingProp,
+      onContextMenu,
+      allowRenderHtml,
+      ...restProps,
+    }),
+    [
+      cols,
+      rows,
+      aggregatorName,
+      tableOptions,
+      subtotalOptions,
+      namesMappingProp,
+      onContextMenu,
+      allowRenderHtml,
+      restProps,
+    ],
+  );
+
+  const clickHandler = useCallback(
+    (
+      pivotData: InstanceType<typeof PivotData>,
+      rowValues: string[],
+      colValues: string[],
+    ) => {
+      const colAttrs = cols;
+      const rowAttrs = rows;
+      const value = pivotData.getAggregator(rowValues, colValues).value();
+      const filters: Record<string, string> = {};
+      const colLimit = Math.min(colAttrs.length, colValues.length);
+      for (let i = 0; i < colLimit; i += 1) {
+        const attr = colAttrs[i];
+        if (colValues[i] !== null) {
+          filters[attr] = colValues[i];
+        }
+      }
+      const rowLimit = Math.min(rowAttrs.length, rowValues.length);
+      for (let i = 0; i < rowLimit; i += 1) {
+        const attr = rowAttrs[i];
+        if (rowValues[i] !== null) {
+          filters[attr] = rowValues[i];
+        }
+      }
+      const { clickCallback } = tableOptions;
+      return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData);
+    },
+    [cols, rows, tableOptions],
+  );
 
-  getBasePivotSettings(): PivotSettings {
+  const clickHeaderHandler = useCallback(
+    (
+      pivotData: InstanceType<typeof PivotData>,
+      values: string[],
+      attrs: string[],
+      attrIdx: number,
+      callback: HeaderClickCallback | undefined,
+      isSubtotal = false,
+      isGrandTotal = false,
+    ) => {
+      const filters: Record<string, string> = {};
+      for (let i = 0; i <= attrIdx; i += 1) {
+        const attr = attrs[i];
+        filters[attr] = values[i];
+      }
+      return (e: MouseEvent) =>
+        callback?.(
+          e,
+          values[attrIdx],
+          filters,
+          pivotData,
+          isSubtotal,
+          isGrandTotal,
+        );
+    },
+    [],
+  );
+
+  const collapseAttr = useCallback(
+    (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) =>
+      (e: MouseEvent<HTMLSpanElement>) => {
+        // Collapse an entire attribute.
+        e.stopPropagation();
+        const keyLen = attrIdx + 1;
+        const collapsed = allKeys
+          .filter((k: string[]) => k.length === keyLen)
+          .map(flatKey);
+
+        const updates: Record<string, boolean> = {};
+        collapsed.forEach((k: string) => {
+          updates[k] = true;
+        });
+
+        if (rowOrCol) {
+          setCollapsedRows(state => ({ ...state, ...updates }));
+        } else {
+          setCollapsedCols(state => ({ ...state, ...updates }));
+        }
+      },
+    [],
+  );
+
+  const expandAttr = useCallback(
+    (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) =>
+      (e: MouseEvent<HTMLSpanElement>) => {
+        // Expand an entire attribute. This implicitly implies expanding all of the
+        // parents as well. It's a bit inefficient but ah well...
+        e.stopPropagation();
+        const updates: Record<string, boolean> = {};
+        allKeys.forEach((k: string[]) => {
+          for (let i = 0; i <= attrIdx; i += 1) {
+            updates[flatKey(k.slice(0, i + 1))] = false;
+          }
+        });
+
+        if (rowOrCol) {
+          setCollapsedRows(state => ({ ...state, ...updates }));
+        } else {
+          setCollapsedCols(state => ({ ...state, ...updates }));
+        }
+      },
+    [],
+  );
+
+  const toggleRowKey = useCallback(
+    (flatRowKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
+      e.stopPropagation();
+      setCollapsedRows(state => ({
+        ...state,
+        [flatRowKey]: !state[flatRowKey],
+      }));
+    },
+    [],
+  );
+
+  const toggleColKey = useCallback(
+    (flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
+      e.stopPropagation();
+      setCollapsedCols(state => ({
+        ...state,
+        [flatColKey]: !state[flatColKey],
+      }));
+    },
+    [],
+  );
+
+  const calcAttrSpans = useCallback((attrArr: string[][], numAttrs: number) => {
+    // Given an array of attribute values (i.e. each element is another array with
+    // the value at every level), compute the spans for every attribute value at
+    // every level. The return value is a nested array of the same shape. It has
+    // -1's for repeated values and the span number otherwise.
+
+    const spans = [];
+    // Index of the last new value
+    const li = Array(numAttrs).map(() => 0);
+    let lv: (string | null)[] = Array(numAttrs).map(() => null);
+    for (let i = 0; i < attrArr.length; i += 1) {
+      // Keep increasing span values as long as the last keys are the same. For
+      // the rest, record spans of 1. Update the indices too.
+      const cv = attrArr[i];
+      const ent = [];
+      let depth = 0;
+      const limit = Math.min(lv.length, cv.length);
+      while (depth < limit && lv[depth] === cv[depth]) {
+        ent.push(-1);
+        spans[li[depth]][depth] += 1;
+        depth += 1;
+      }
+      while (depth < cv.length) {
+        li[depth] = i;
+        ent.push(1);
+        depth += 1;
+      }
+      spans.push(ent);
+      lv = cv;
+    }
+    return spans;
+  }, []);
+
+  const getAggregatedData = useCallback(
+    (
+      pivotData: InstanceType<typeof PivotData>,
+      visibleColName: string[],
+      rowPartialOnTop: boolean | undefined,
+    ) => {
+      // Transforms flat row keys into a hierarchical group structure where each level
+      // represents a grouping dimension. For each row key path, it calculates the
+      // aggregated value for the specified column and builds a nested object that
+      // preserves the hierarchy while storing aggregation values at each level.
+      const groups: Record<string, HierarchicalNode> = {};
+      const rowsData = pivotData.rowKeys;
+      rowsData.forEach(rowKey => {
+        const aggValue =
+          pivotData.getAggregator(rowKey, visibleColName).value() ?? 0;
+
+        if (rowPartialOnTop) {
+          const parent = rowKey
+            .slice(0, -1)
+            .reduce(
+              (acc: Record<string, HierarchicalNode>, key: string) =>
+                (acc[key] ??= {}) as Record<string, HierarchicalNode>,
+              groups,
+            );
+          parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
+        } else {
+          rowKey.reduce(
+            (acc: Record<string, HierarchicalNode>, key: string) => {
+              acc[key] = acc[key] || { currentVal: 0 };
+              (acc[key] as HierarchicalNode).currentVal = aggValue as number;
+              return acc[key] as Record<string, HierarchicalNode>;
+            },
+            groups,
+          );
+        }
+      });
+      return groups;
+    },
+    [],
+  );
+
+  const sortAndCacheData = useCallback(
+    (
+      groups: Record<string, HierarchicalNode>,
+      sortOrder: string,
+      rowEnabled: boolean | undefined,
+      rowPartialOnTop: boolean | undefined,
+      maxRowIndex: number,
+    ) => {
+      // Processes hierarchical data by first sorting it according to the specified order
+      // and then converting the sorted structure into a flat array format. This function
+      // serves as an intermediate step between hierarchical data representation and
+      // flat array representation needed for rendering.
+      const sortedGroups = sortHierarchicalObject(
+        groups,
+        sortOrder,
+        rowPartialOnTop,
+      );
+      return convertToArray(
+        sortedGroups,
+        rowEnabled,
+        rowPartialOnTop,
+        maxRowIndex,
+      );
+    },
+    [],
+  );
+
+  const getBasePivotSettings = useCallback((): PivotSettings => {
     // One-time extraction of pivot settings that we'll use throughout the render.
 
-    const { props } = this;
-    const colAttrs = props.cols;
-    const rowAttrs = props.rows;
+    const colAttrs = cols;
+    const rowAttrs = rows;
 
-    const tableOptions: TableOptions = {
+    const mergedTableOptions: TableOptions = {
       rowTotals: true,
       colTotals: true,
-      ...props.tableOptions,
+      ...tableOptions,
     };
-    const rowTotals = tableOptions.rowTotals || colAttrs.length === 0;
-    const colTotals = tableOptions.colTotals || rowAttrs.length === 0;
+    const rowTotals = mergedTableOptions.rowTotals || colAttrs.length === 0;
+    const colTotals = mergedTableOptions.colTotals || rowAttrs.length === 0;
 
-    const namesMapping = props.namesMapping || {};
-    const subtotalOptions: Required<
+    const namesMapping = namesMappingProp || {};
+    const mergedSubtotalOptions: Required<
       Pick<SubtotalOptions, 'arrowCollapsed' | 'arrowExpanded'>
     > &
       SubtotalOptions = {
       arrowCollapsed: '\u25B2',
       arrowExpanded: '\u25BC',
-      ...props.subtotalOptions,
+      ...subtotalOptions,
     };
 
     const colSubtotalDisplay: SubtotalDisplay = {
       displayOnTop: false,
-      enabled: tableOptions.colSubTotals,
+      enabled: mergedTableOptions.colSubTotals,
       hideOnExpand: false,
-      ...subtotalOptions.colSubtotalDisplay,
+      ...mergedSubtotalOptions.colSubtotalDisplay,
     };
 
     const rowSubtotalDisplay: SubtotalDisplay = {
       displayOnTop: false,
-      enabled: tableOptions.rowSubTotals,
+      enabled: mergedTableOptions.rowSubTotals,
       hideOnExpand: false,
-      ...subtotalOptions.rowSubtotalDisplay,
+      ...mergedSubtotalOptions.rowSubtotalDisplay,
     };
 
     const pivotData = new PivotData(props as Record<string, unknown>, {
@@ -354,14 +605,14 @@ export class TableRenderer extends Component<
     const rowTotalCallbacks: Record<string, (e: MouseEvent) => void> = {};
     const colTotalCallbacks: Record<string, (e: MouseEvent) => void> = {};
     let grandTotalCallback: ((e: MouseEvent) => void) | null = null;
-    if (tableOptions.clickCallback) {
+    if (mergedTableOptions.clickCallback) {
       rowKeys.forEach(rowKey => {
         const flatRowKey = flatKey(rowKey);
         if (!(flatRowKey in cellCallbacks)) {
           cellCallbacks[flatRowKey] = {};
         }
         colKeys.forEach(colKey => {
-          cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler(
+          cellCallbacks[flatRowKey][flatKey(colKey)] = clickHandler(
             pivotData,
             rowKey,
             colKey,
@@ -372,7 +623,7 @@ export class TableRenderer extends Component<
       // Add in totals as well.
       if (rowTotals) {
         rowKeys.forEach(rowKey => {
-          rowTotalCallbacks[flatKey(rowKey)] = this.clickHandler(
+          rowTotalCallbacks[flatKey(rowKey)] = clickHandler(
             pivotData,
             rowKey,
             [],
@@ -381,7 +632,7 @@ export class TableRenderer extends Component<
       }
       if (colTotals) {
         colKeys.forEach(colKey => {
-          colTotalCallbacks[flatKey(colKey)] = this.clickHandler(
+          colTotalCallbacks[flatKey(colKey)] = clickHandler(
             pivotData,
             [],
             colKey,
@@ -389,7 +640,7 @@ export class TableRenderer extends Component<
         });
       }
       if (rowTotals && colTotals) {
-        grandTotalCallback = this.clickHandler(pivotData, [], []);
+        grandTotalCallback = clickHandler(pivotData, [], []);
       }
     }
 
@@ -401,8 +652,8 @@ export class TableRenderer extends Component<
       rowKeys,
       rowTotals,
       colTotals,
-      arrowCollapsed: subtotalOptions.arrowCollapsed,
-      arrowExpanded: subtotalOptions.arrowExpanded,
+      arrowCollapsed: mergedSubtotalOptions.arrowCollapsed,
+      arrowExpanded: mergedSubtotalOptions.arrowExpanded,
       colSubtotalDisplay,
       rowSubtotalDisplay,
       cellCallbacks,
@@ -410,242 +661,113 @@ export class TableRenderer extends Component<
       colTotalCallbacks,
       grandTotalCallback,
       namesMapping,
-      allowRenderHtml: props.allowRenderHtml,
-    };
-  }
-
-  clickHandler(
-    pivotData: InstanceType<typeof PivotData>,
-    rowValues: string[],
-    colValues: string[],
-  ) {
-    const colAttrs = this.props.cols;
-    const rowAttrs = this.props.rows;
-    const value = pivotData.getAggregator(rowValues, colValues).value();
-    const filters: Record<string, string> = {};
-    const colLimit = Math.min(colAttrs.length, colValues.length);
-    for (let i = 0; i < colLimit; i += 1) {
-      const attr = colAttrs[i];
-      if (colValues[i] !== null) {
-        filters[attr] = colValues[i];
-      }
-    }
-    const rowLimit = Math.min(rowAttrs.length, rowValues.length);
-    for (let i = 0; i < rowLimit; i += 1) {
-      const attr = rowAttrs[i];
-      if (rowValues[i] !== null) {
-        filters[attr] = rowValues[i];
-      }
-    }
-    const { clickCallback } = this.props.tableOptions;
-    return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData);
-  }
-
-  clickHeaderHandler(
-    pivotData: InstanceType<typeof PivotData>,
-    values: string[],
-    attrs: string[],
-    attrIdx: number,
-    callback: HeaderClickCallback | undefined,
-    isSubtotal = false,
-    isGrandTotal = false,
-  ) {
-    const filters: Record<string, string> = {};
-    for (let i = 0; i <= attrIdx; i += 1) {
-      const attr = attrs[i];
-      filters[attr] = values[i];
-    }
-    return (e: MouseEvent) =>
-      callback?.(
-        e,
-        values[attrIdx],
-        filters,
-        pivotData,
-        isSubtotal,
-        isGrandTotal,
-      );
-  }
-
-  collapseAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
-    return (e: MouseEvent<HTMLSpanElement>) => {
-      // Collapse an entire attribute.
-      e.stopPropagation();
-      const keyLen = attrIdx + 1;
-      const collapsed = allKeys
-        .filter((k: string[]) => k.length === keyLen)
-        .map(flatKey);
-
-      const updates: Record<string, boolean> = {};
-      collapsed.forEach((k: string) => {
-        updates[k] = true;
-      });
-
-      if (rowOrCol) {
-        this.setState(state => ({
-          collapsedRows: { ...state.collapsedRows, ...updates },
-        }));
-      } else {
-        this.setState(state => ({
-          collapsedCols: { ...state.collapsedCols, ...updates },
-        }));
-      }
-    };
-  }
-
-  expandAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
-    return (e: MouseEvent<HTMLSpanElement>) => {
-      // Expand an entire attribute. This implicitly implies expanding all of the
-      // parents as well. It's a bit inefficient but ah well...
-      e.stopPropagation();
-      const updates: Record<string, boolean> = {};
-      allKeys.forEach((k: string[]) => {
-        for (let i = 0; i <= attrIdx; i += 1) {
-          updates[flatKey(k.slice(0, i + 1))] = false;
-        }
-      });
-
-      if (rowOrCol) {
-        this.setState(state => ({
-          collapsedRows: { ...state.collapsedRows, ...updates },
-        }));
-      } else {
-        this.setState(state => ({
-          collapsedCols: { ...state.collapsedCols, ...updates },
-        }));
-      }
-    };
-  }
-
-  toggleRowKey(flatRowKey: string) {
-    return (e: MouseEvent<HTMLSpanElement>) => {
-      e.stopPropagation();
-      this.setState(state => ({
-        collapsedRows: {
-          ...state.collapsedRows,
-          [flatRowKey]: !state.collapsedRows[flatRowKey],
-        },
-      }));
-    };
-  }
-
-  toggleColKey(flatColKey: string) {
-    return (e: MouseEvent<HTMLSpanElement>) => {
-      e.stopPropagation();
-      this.setState(state => ({
-        collapsedCols: {
-          ...state.collapsedCols,
-          [flatColKey]: !state.collapsedCols[flatColKey],
-        },
-      }));
+      allowRenderHtml,
     };
-  }
-
-  calcAttrSpans(attrArr: string[][], numAttrs: number) {
-    // Given an array of attribute values (i.e. each element is another array with
-    // the value at every level), compute the spans for every attribute value at
-    // every level. The return value is a nested array of the same shape. It has
-    // -1's for repeated values and the span number otherwise.
-
-    const spans = [];
-    // Index of the last new value
-    const li = Array(numAttrs).map(() => 0);
-    let lv: (string | null)[] = Array(numAttrs).map(() => null);
-    for (let i = 0; i < attrArr.length; i += 1) {
-      // Keep increasing span values as long as the last keys are the same. For
-      // the rest, record spans of 1. Update the indices too.
-      const cv = attrArr[i];
-      const ent = [];
-      let depth = 0;
-      const limit = Math.min(lv.length, cv.length);
-      while (depth < limit && lv[depth] === cv[depth]) {
-        ent.push(-1);
-        spans[li[depth]][depth] += 1;
-        depth += 1;
-      }
-      while (depth < cv.length) {
-        li[depth] = i;
-        ent.push(1);
-        depth += 1;
-      }
-      spans.push(ent);
-      lv = cv;
-    }
-    return spans;
-  }
+  }, [
+    cols,
+    rows,
+    tableOptions,
+    namesMappingProp,
+    subtotalOptions,
+    props,
+    allowRenderHtml,
+    clickHandler,
+  ]);
+
+  const visibleKeys = useCallback(
+    (
+      keys: string[][],
+      collapsed: Record<string, boolean>,
+      numAttrs: number,
+      subtotalDisplay: SubtotalDisplay,
+    ) =>
+      keys.filter(
+        (key: string[]) =>
+          // Is the key hidden by one of its parents?
+          !key.some(
+            (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))],
+          ) &&
+          // Leaf key.
+          (key.length === numAttrs ||
+            // Children hidden. Must show total.
+            flatKey(key) in collapsed ||
+            // Don't hide totals.
+            !subtotalDisplay.hideOnExpand),
+      ),
+    [],
+  );
 
-  getAggregatedData(
-    pivotData: InstanceType<typeof PivotData>,
-    visibleColName: string[],
-    rowPartialOnTop: boolean | undefined,
-  ) {
-    // Transforms flat row keys into a hierarchical group structure where each level
-    // represents a grouping dimension. For each row key path, it calculates the
-    // aggregated value for the specified column and builds a nested object that
-    // preserves the hierarchy while storing aggregation values at each level.
-    const groups: Record<string, HierarchicalNode> = {};
-    const rows = pivotData.rowKeys;
-    rows.forEach(rowKey => {
-      const aggValue =
-        pivotData.getAggregator(rowKey, visibleColName).value() ?? 0;
-
-      if (rowPartialOnTop) {
-        const parent = rowKey
-          .slice(0, -1)
-          .reduce(
-            (acc: Record<string, HierarchicalNode>, key: string) =>
-              (acc[key] ??= {}) as Record<string, HierarchicalNode>,
-            groups,
-          );
-        parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
-      } else {
-        rowKey.reduce((acc: Record<string, HierarchicalNode>, key: string) => {
-          acc[key] = acc[key] || { currentVal: 0 };
-          (acc[key] as HierarchicalNode).currentVal = aggValue as number;
-          return acc[key] as Record<string, HierarchicalNode>;
-        }, groups);
-      }
-    });
-    return groups;
-  }
+  const isDashboardEditMode = useCallback(
+    () => document.contains(document.querySelector('.dashboard--editing')),
+    [],
+  );
 
-  sortAndCacheData(
-    groups: Record<string, HierarchicalNode>,
-    sortOrder: string,
-    rowEnabled: boolean | undefined,
-    rowPartialOnTop: boolean | undefined,
-    maxRowIndex: number,
-  ) {
-    // Processes hierarchical data by first sorting it according to the specified order
-    // and then converting the sorted structure into a flat array format. This function
-    // serves as an intermediate step between hierarchical data representation and
-    // flat array representation needed for rendering.
-    const sortedGroups = sortHierarchicalObject(
-      groups,
-      sortOrder,
-      rowPartialOnTop,
-    );
-    return convertToArray(
-      sortedGroups,
-      rowEnabled,
-      rowPartialOnTop,
-      maxRowIndex,
-    );
-  }
+  // Compute base pivot settings, memoized based on relevant props
+  const basePivotSettings = useMemo(() => {
+    // Clear sort cache when props change
+    sortCacheRef.current.clear();
+    return getBasePivotSettings();
+  }, [getBasePivotSettings]);
+
+  // Reset sort state when props change
+  useEffect(() => {
+    setSortingOrder([]);
+    setActiveSortColumn(null);
+    setSortedRowKeys(null);
+  }, [props]);
+
+  // Use sorted row keys if available, otherwise use base row keys
+  const effectiveRowKeys = sortedRowKeys ?? basePivotSettings.rowKeys;
+
+  const {
+    colAttrs,
+    rowAttrs,
+    colKeys,
+    colTotals,
+    rowSubtotalDisplay,
+    colSubtotalDisplay,
+  } = basePivotSettings;
+
+  const rowKeys = effectiveRowKeys;
+
+  // Need to account for exclusions to compute the effective row
+  // and column keys.
+  const visibleRowKeys = visibleKeys(
+    rowKeys,
+    collapsedRows,
+    rowAttrs.length,
+    rowSubtotalDisplay,
+  );
+  const visibleColKeys = visibleKeys(
+    colKeys,
+    collapsedCols,
+    colAttrs.length,
+    colSubtotalDisplay,
+  );
 
-  sortData(
-    columnIndex: number,
-    visibleColKeys: string[][],
-    pivotData: InstanceType<typeof PivotData>,
-    maxRowIndex: number,
-  ) {
-    // Handles column sorting with direction toggling (asc/desc) and implements
-    // caching mechanism to avoid redundant sorting operations. When sorting the same
-    // column multiple times, it cycles through sorting directions. Uses composite
-    // cache keys based on sorting parameters for optimal performance.
-    this.setState(state => {
-      const { sortingOrder, activeSortColumn } = state;
-
-      const newSortingOrder = [];
+  const pivotSettings: PivotSettings = {
+    visibleRowKeys,
+    maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)),
+    visibleColKeys,
+    maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
+    rowAttrSpans: calcAttrSpans(visibleRowKeys, rowAttrs.length),
+    colAttrSpans: calcAttrSpans(visibleColKeys, colAttrs.length),
+    allowRenderHtml,
+    ...basePivotSettings,
+  };
+
+  const sortData = useCallback(
+    (
+      columnIndex: number,
+      visColKeys: string[][],
+      pivotData: InstanceType<typeof PivotData>,
+      maxRowIndex: number,
+    ) => {
+      // Handles column sorting with direction toggling (asc/desc) and implements
+      // caching mechanism to avoid redundant sorting operations. When sorting the same
+      // column multiple times, it cycles through sorting directions. Uses composite
+      // cache keys based on sorting parameters for optimal performance.
+      const newSortingOrder: string[] = [];
       let newDirection = 'asc';
 
       if (activeSortColumn === columnIndex) {
@@ -658,702 +780,674 @@ export class TableRenderer extends Component<
       };
       newSortingOrder[columnIndex] = newDirection;
 
-      const cacheKey = `${columnIndex}-${visibleColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`;
+      const cacheKey = `${columnIndex}-${visColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`;
       let newRowKeys;
-      if (this.sortCache.has(cacheKey)) {
-        const cachedRowKeys = this.sortCache.get(cacheKey);
+      if (sortCacheRef.current.has(cacheKey)) {
+        const cachedRowKeys = sortCacheRef.current.get(cacheKey);
         newRowKeys = cachedRowKeys;
       } else {
-        const groups = this.getAggregatedData(
+        const groups = getAggregatedData(
           pivotData,
-          visibleColKeys[columnIndex],
+          visColKeys[columnIndex],
           rowPartialOnTop,
         );
-        const sortedRowKeys = this.sortAndCacheData(
+        const computedSortedRowKeys = sortAndCacheData(
           groups,
           newDirection,
           rowEnabled,
           rowPartialOnTop,
           maxRowIndex,
         );
-        this.sortCache.set(cacheKey, sortedRowKeys);
-        newRowKeys = sortedRowKeys;
+        sortCacheRef.current.set(cacheKey, computedSortedRowKeys);
+        newRowKeys = computedSortedRowKeys;
       }
-      this.cachedBasePivotSettings = {
-        ...this.cachedBasePivotSettings!,
-        rowKeys: newRowKeys!,
-      };
 
-      return {
-        sortingOrder: newSortingOrder,
-        activeSortColumn: columnIndex,
-      };
-    });
-  }
+      setSortedRowKeys(newRowKeys!);
+      setSortingOrder(newSortingOrder);
+      setActiveSortColumn(columnIndex);
+    },
+    [activeSortColumn, sortingOrder, getAggregatedData, sortAndCacheData],
+  );
 
-  renderColHeaderRow(
-    attrName: string,
-    attrIdx: number,
-    pivotSettings: PivotSettings,
-  ) {
-    // Render a single row in the column header at the top of the pivot table.
+  const renderColHeaderRow = useCallback(
+    (attrName: string, attrIdx: number, settings: PivotSettings) => {
+      // Render a single row in the column header at the top of the pivot table.
+
+      const {
+        rowAttrs: settingsRowAttrs,
+        colAttrs: settingsColAttrs,
+        colKeys: settingsColKeys,
+        visibleColKeys: settingsVisibleColKeys,
+        colAttrSpans,
+        rowTotals,
+        arrowExpanded,
+        arrowCollapsed,
+        colSubtotalDisplay: settingsColSubtotalDisplay,
+        maxColVisible,
+        pivotData,
+        namesMapping,
+        allowRenderHtml: settingsAllowRenderHtml,
+      } = settings;
+      const {
+        highlightHeaderCellsOnHover,
+        omittedHighlightHeaderGroups = [],
+        highlightedHeaderCells,
+        dateFormatters,
+      } = tableOptions;
+
+      if (!settingsVisibleColKeys || !colAttrSpans) {
+        return null;
+      }
 
-    const {
-      rowAttrs,
-      colAttrs,
-      colKeys,
-      visibleColKeys,
-      colAttrSpans,
-      rowTotals,
-      arrowExpanded,
-      arrowCollapsed,
-      colSubtotalDisplay,
-      maxColVisible,
-      pivotData,
-      namesMapping,
-      allowRenderHtml,
-    } = pivotSettings;
-    const {
-      highlightHeaderCellsOnHover,
-      omittedHighlightHeaderGroups = [],
-      highlightedHeaderCells,
-      dateFormatters,
-    } = this.props.tableOptions;
-
-    if (!visibleColKeys || !colAttrSpans) {
-      return null;
-    }
+      const spaceCell =
+        attrIdx === 0 && settingsRowAttrs.length !== 0 ? (
+          <th
+            key="padding"
+            colSpan={settingsRowAttrs.length}
+            rowSpan={settingsColAttrs.length}
+            aria-hidden="true"
+          />
+        ) : null;
+
+      const needToggle =
+        settingsColSubtotalDisplay.enabled === true &&
+        attrIdx !== settingsColAttrs.length - 1;
+      let arrowClickHandle = null;
+      let subArrow = null;
+      if (needToggle) {
+        arrowClickHandle =
+          attrIdx + 1 < maxColVisible!
+            ? collapseAttr(false, attrIdx, settingsColKeys)
+            : expandAttr(false, attrIdx, settingsColKeys);
+        subArrow =
+          attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed;
+      }
+      const attrNameCell = (
+        <th key="label" className="pvtAxisLabel">
+          {displayHeaderCell(
+            needToggle,
+            subArrow,
+            arrowClickHandle,
+            attrName,
+            namesMapping,
+            settingsAllowRenderHtml,
+          )}
+        </th>
+      );
 
-    const spaceCell =
-      attrIdx === 0 && rowAttrs.length !== 0 ? (
-        <th
-          key="padding"
-          colSpan={rowAttrs.length}
-          rowSpan={colAttrs.length}
-          aria-hidden="true"
-        />
-      ) : null;
-
-    const needToggle =
-      colSubtotalDisplay.enabled === true && attrIdx !== colAttrs.length - 1;
-    let arrowClickHandle = null;
-    let subArrow = null;
-    if (needToggle) {
-      arrowClickHandle =
-        attrIdx + 1 < maxColVisible!
-          ? this.collapseAttr(false, attrIdx, colKeys)
-          : this.expandAttr(false, attrIdx, colKeys);
-      subArrow = attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed;
-    }
-    const attrNameCell = (
-      <th key="label" className="pvtAxisLabel">
-        {displayHeaderCell(
-          needToggle,
-          subArrow,
-          arrowClickHandle,
-          attrName,
-          namesMapping,
-          allowRenderHtml,
-        )}
-      </th>
-    );
-
-    const attrValueCells = [];
-    const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0;
-    // Iterate through columns. Jump over duplicate values.
-    let i = 0;
-    while (i < visibleColKeys.length) {
-      let handleContextMenu: ((e: MouseEvent) => void) | undefined;
-      const colKey = visibleColKeys[i];
-      const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1;
-      let colLabelClass = 'pvtColLabel';
-      if (attrIdx < colKey.length) {
-        if (!omittedHighlightHeaderGroups.includes(colAttrs[attrIdx])) {
-          if (highlightHeaderCellsOnHover) {
-            colLabelClass += ' hoverable';
+      const attrValueCells = [];
+      const rowIncrSpan = settingsRowAttrs.length !== 0 ? 1 : 0;
+      // Iterate through columns. Jump over duplicate values.
+      let i = 0;
+      while (i < settingsVisibleColKeys.length) {
+        let handleContextMenu: ((e: MouseEvent) => void) | undefined;
+        const colKey = settingsVisibleColKeys[i];
+        const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1;
+        let colLabelClass = 'pvtColLabel';
+        if (attrIdx < colKey.length) {
+          if (
+            !omittedHighlightHeaderGroups.includes(settingsColAttrs[attrIdx])
+          ) {
+            if (highlightHeaderCellsOnHover) {
+              colLabelClass += ' hoverable';
+            }
+            handleContextMenu = (e: MouseEvent) =>
+              onContextMenu(e, colKey, undefined, {
+                [attrName]: colKey[attrIdx],
+              });
           }
-          handleContextMenu = (e: MouseEvent) =>
-            this.props.onContextMenu(e, colKey, undefined, {
-              [attrName]: colKey[attrIdx],
-            });
-        }
-        if (
-          highlightedHeaderCells &&
-          Array.isArray(highlightedHeaderCells[colAttrs[attrIdx]]) &&
-          highlightedHeaderCells[colAttrs[attrIdx]].includes(colKey[attrIdx])
-        ) {
-          colLabelClass += ' active';
-        }
-        const maxRowIndex = pivotSettings.maxRowVisible!;
-        const mColVisible = pivotSettings.maxColVisible!;
-        const visibleSortIcon = mColVisible - 1 === attrIdx;
-        const columnName = colKey[mColVisible - 1];
-
-        const rowSpan = 1 + (attrIdx === colAttrs.length - 1 ? rowIncrSpan : 0);
-        const flatColKey = flatKey(colKey.slice(0, attrIdx + 1));
-        const onArrowClick = needToggle ? this.toggleColKey(flatColKey) : null;
-        const getSortIcon = (key: number) => {
-          const { activeSortColumn, sortingOrder } = this.state;
-
-          if (activeSortColumn !== key) {
+          if (
+            highlightedHeaderCells &&
+            Array.isArray(highlightedHeaderCells[settingsColAttrs[attrIdx]]) &&
+            highlightedHeaderCells[settingsColAttrs[attrIdx]].includes(
+              colKey[attrIdx],
+            )
+          ) {
+            colLabelClass += ' active';
+          }
+          const maxRowIndex = settings.maxRowVisible!;
+          const mColVisible = settings.maxColVisible!;
+          const visibleSortIcon = mColVisible - 1 === attrIdx;
+          const columnName = colKey[mColVisible - 1];
+
+          const rowSpan =
+            1 + (attrIdx === settingsColAttrs.length - 1 ? rowIncrSpan : 0);
+          const flatColKey = flatKey(colKey.slice(0, attrIdx + 1));
+          const onArrowClick = needToggle ? toggleColKey(flatColKey) : null;
+          const getSortIcon = (key: number) => {
+            if (activeSortColumn !== key) {
+              return (
+                <FaSort
+                  onClick={() =>
+                    sortData(
+                      key,
+                      settingsVisibleColKeys,
+                      pivotData,
+                      maxRowIndex,
+                    )
+                  }
+                />
+              );
+            }
+
+            const SortIcon =
+              sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
             return (
-              <FaSort
+              <SortIcon
                 onClick={() =>
-                  this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
+                  sortData(key, settingsVisibleColKeys, pivotData, maxRowIndex)
                 }
               />
             );
-          }
-
-          const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
-          return (
-            <SortIcon
-              onClick={() =>
-                this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
-              }
-            />
+          };
+          const headerCellFormattedValue =
+            dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
+          attrValueCells.push(
+            <th
+              className={colLabelClass}
+              key={`colKey-${flatColKey}`}
+              colSpan={colSpan}
+              rowSpan={rowSpan}
+              role="columnheader button"
+              onClick={clickHeaderHandler(
+                pivotData,
+                colKey,
+                cols,
+                attrIdx,
+                tableOptions.clickColumnHeaderCallback,
+              )}
+              onContextMenu={handleContextMenu}
+            >
+              {displayHeaderCell(
+                needToggle,
+                collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded,
+                onArrowClick,
+                headerCellFormattedValue,
+                namesMapping,
+                settingsAllowRenderHtml,
+              )}
+              <span
+                role="columnheader"
+                tabIndex={0}
+                // Prevents event bubbling to avoid conflict with column header click handlers
+                // Ensures sort operation executes without triggering cross-filtration
+                onClick={e => {
+                  e.stopPropagation();
+                }}
+                aria-label={
+                  activeSortColumn === i
+                    ? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
+                    : undefined
+                }
+              >
+                {visibleSortIcon && getSortIcon(i)}
+              </span>
+            </th>,
           );
-        };
-        const headerCellFormattedValue =
-          dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
-        attrValueCells.push(
+        } else if (attrIdx === colKey.length) {
+          const rowSpan = settingsColAttrs.length - colKey.length + rowIncrSpan;
+          attrValueCells.push(
+            <th
+              className={`${colLabelClass} pvtSubtotalLabel`}
+              key={`colKeyBuffer-${flatKey(colKey)}`}
+              colSpan={colSpan}
+              rowSpan={rowSpan}
+              role="columnheader button"
+              onClick={clickHeaderHandler(
+                pivotData,
+                colKey,
+                cols,
+                attrIdx,
+                tableOptions.clickColumnHeaderCallback,
+                true,
+              )}
+            >
+              {t('Subtotal')}
+            </th>,
+          );
+        }
+        // The next colSpan columns will have the same value anyway...
+        i += colSpan;
+      }
+
+      const totalCell =
+        attrIdx === 0 && rowTotals ? (
           <th
-            className={colLabelClass}
-            key={`colKey-${flatColKey}`}
-            colSpan={colSpan}
-            rowSpan={rowSpan}
+            key="total"
+            className="pvtTotalLabel"
+            rowSpan={
+              settingsColAttrs.length + Math.min(settingsRowAttrs.length, 1)
+            }
             role="columnheader button"
-            onClick={this.clickHeaderHandler(
+            onClick={clickHeaderHandler(
               pivotData,
-              colKey,
-              this.props.cols,
+              [],
+              cols,
               attrIdx,
-              this.props.tableOptions.clickColumnHeaderCallback,
+              tableOptions.clickColumnHeaderCallback,
+              false,
+              true,
             )}
-            onContextMenu={handleContextMenu}
           >
-            {displayHeaderCell(
-              needToggle,
-              this.state.collapsedCols[flatColKey]
-                ? arrowCollapsed
-                : arrowExpanded,
-              onArrowClick,
-              headerCellFormattedValue,
-              namesMapping,
-              allowRenderHtml,
-            )}
-            <span
-              role="columnheader"
-              tabIndex={0}
-              // Prevents event bubbling to avoid conflict with column header click handlers
-              // Ensures sort operation executes without triggering cross-filtration
-              onClick={e => {
-                e.stopPropagation();
-              }}
-              aria-label={
-                this.state.activeSortColumn === i
-                  ? `Sorted by ${columnName} ${this.state.sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
-                  : undefined
-              }
-            >
-              {visibleSortIcon && getSortIcon(i)}
-            </span>
-          </th>,
-        );
-      } else if (attrIdx === colKey.length) {
-        const rowSpan = colAttrs.length - colKey.length + rowIncrSpan;
-        attrValueCells.push(
+            {t('Total (%(aggregatorName)s)', {
+              aggregatorName: t(aggregatorName),
+            })}
+          </th>
+        ) : null;
+
+      const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
+      return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
+    },
+    [
+      tableOptions,
+      onContextMenu,
+      collapseAttr,
+      expandAttr,
+      toggleColKey,
+      clickHeaderHandler,
+      cols,
+      aggregatorName,
+      activeSortColumn,
+      sortingOrder,
+      collapsedCols,
+      sortData,
+    ],
+  );
+
+  const renderRowHeaderRow = useCallback(
+    (settings: PivotSettings) => {
+      // Render just the attribute names of the rows (the actual attribute values
+      // will show up in the individual rows).
+
+      const {
+        rowAttrs: settingsRowAttrs,
+        colAttrs: settingsColAttrs,
+        rowKeys: settingsRowKeys,
+        arrowCollapsed,
+        arrowExpanded,
+        rowSubtotalDisplay: settingsRowSubtotalDisplay,
+        maxRowVisible,
+        pivotData,
+        namesMapping,
+        allowRenderHtml: settingsAllowRenderHtml,
+      } = settings;
+      return (
+        <tr key="rowHdr">
+          {settingsRowAttrs.map((r, i) => {
+            const needLabelToggle =
+              settingsRowSubtotalDisplay.enabled === true &&
+              i !== settingsRowAttrs.length - 1;
+            let arrowClickHandle = null;
+            let subArrow = null;
+            if (needLabelToggle) {
+              arrowClickHandle =
+                i + 1 < maxRowVisible!
+                  ? collapseAttr(true, i, settingsRowKeys)
+                  : expandAttr(true, i, settingsRowKeys);
+              subArrow =
+                i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed;
+            }
+            return (
+              <th className="pvtAxisLabel" key={`rowAttr-${i}`}>
+                {displayHeaderCell(
+                  needLabelToggle,
+                  subArrow,
+                  arrowClickHandle,
+                  r,
+                  namesMapping,
+                  settingsAllowRenderHtml,
+                )}
+              </th>
+            );
+          })}
           <th
-            className={`${colLabelClass} pvtSubtotalLabel`}
-            key={`colKeyBuffer-${flatKey(colKey)}`}
-            colSpan={colSpan}
-            rowSpan={rowSpan}
+            className="pvtTotalLabel"
+            key="padding"
             role="columnheader button"
-            onClick={this.clickHeaderHandler(
+            onClick={clickHeaderHandler(
               pivotData,
-              colKey,
-              this.props.cols,
-              attrIdx,
-              this.props.tableOptions.clickColumnHeaderCallback,
+              [],
+              rows,
+              0,
+              tableOptions.clickRowHeaderCallback,
+              false,
               true,
             )}
           >
-            {t('Subtotal')}
-          </th>,
-        );
-      }
-      // The next colSpan columns will have the same value anyway...
-      i += colSpan;
-    }
-
-    const totalCell =
-      attrIdx === 0 && rowTotals ? (
-        <th
-          key="total"
-          className="pvtTotalLabel"
-          rowSpan={colAttrs.length + Math.min(rowAttrs.length, 1)}
-          role="columnheader button"
-          onClick={this.clickHeaderHandler(
-            pivotData,
-            [],
-            this.props.cols,
-            attrIdx,
-            this.props.tableOptions.clickColumnHeaderCallback,
-            false,
-            true,
-          )}
-        >
-          {t('Total (%(aggregatorName)s)', {
-            aggregatorName: t(this.props.aggregatorName),
-          })}
-        </th>
-      ) : null;
-
-    const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
-    return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
-  }
+            {settingsColAttrs.length === 0
+              ? t('Total (%(aggregatorName)s)', {
+                  aggregatorName: t(aggregatorName),
+                })
+              : null}
+          </th>
+        </tr>
+      );
+    },
+    [
+      collapseAttr,
+      expandAttr,
+      clickHeaderHandler,
+      rows,
+      tableOptions.clickRowHeaderCallback,
+      aggregatorName,
+    ],
+  );
 
-  renderRowHeaderRow(pivotSettings: PivotSettings) {
-    // Render just the attribute names of the rows (the actual attribute values
-    // will show up in the individual rows).
+  const renderTableRow = useCallback(
+    (rowKey: string[], rowIdx: number, settings: PivotSettings) => {
+      // Render a single row in the pivot table.
 
-    const {
-      rowAttrs,
-      colAttrs,
-      rowKeys,
-      arrowCollapsed,
-      arrowExpanded,
-      rowSubtotalDisplay,
-      maxRowVisible,
-      pivotData,
-      namesMapping,
-      allowRenderHtml,
-    } = pivotSettings;
-    return (
-      <tr key="rowHdr">
-        {rowAttrs.map((r, i) => {
-          const needLabelToggle =
-            rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1;
-          let arrowClickHandle = null;
-          let subArrow = null;
-          if (needLabelToggle) {
-            arrowClickHandle =
-              i + 1 < maxRowVisible!
-                ? this.collapseAttr(true, i, rowKeys)
-                : this.expandAttr(true, i, rowKeys);
-            subArrow = i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed;
+      const {
+        rowAttrs: settingsRowAttrs,
+        colAttrs: settingsColAttrs,
+        rowAttrSpans,
+        visibleColKeys: settingsVisibleColKeys,
+        pivotData,
+        rowTotals,
+        rowSubtotalDisplay: settingsRowSubtotalDisplay,
+        arrowExpanded,
+        arrowCollapsed,
+        cellCallbacks,
+        rowTotalCallbacks,
+        namesMapping,
+        allowRenderHtml: settingsAllowRenderHtml,
+      } = settings;
+
+      const {
+        highlightHeaderCellsOnHover,
+        omittedHighlightHeaderGroups = [],
+        highlightedHeaderCells,
+        cellColorFormatters,
+        dateFormatters,
+      } = tableOptions;
+      const flatRowKey = flatKey(rowKey);
+
+      const colIncrSpan = settingsColAttrs.length !== 0 ? 1 : 0;
+      const attrValueCells = rowKey.map((r: string, i: number) => {
+        let handleContextMenu: ((e: MouseEvent) => void) | undefined;
+        let valueCellClassName = 'pvtRowLabel';
+        if (!omittedHighlightHeaderGroups.includes(settingsRowAttrs[i])) {
+          if (highlightHeaderCellsOnHover) {
+            valueCellClassName += ' hoverable';
           }
+          handleContextMenu = (e: MouseEvent) =>
+            onContextMenu(e, undefined, rowKey, {
+              [settingsRowAttrs[i]]: r,
+            });
+        }
+        if (
+          highlightedHeaderCells &&
+          Array.isArray(highlightedHeaderCells[settingsRowAttrs[i]]) &&
+          highlightedHeaderCells[settingsRowAttrs[i]].includes(r)
+        ) {
+          valueCellClassName += ' active';
+        }
+        const rowSpan = rowAttrSpans![rowIdx][i];
+        if (rowSpan > 0) {
+          const flatRowKeySlice = flatKey(rowKey.slice(0, i + 1));
+          const colSpan =
+            1 + (i === settingsRowAttrs.length - 1 ? colIncrSpan : 0);
+          const needRowToggle =
+            settingsRowSubtotalDisplay.enabled === true &&
+            i !== settingsRowAttrs.length - 1;
+          const onArrowClick = needRowToggle
+            ? toggleRowKey(flatRowKeySlice)
+            : null;
+
+          const headerCellFormattedValue =
+            dateFormatters?.[settingsRowAttrs[i]]?.(r) ?? r;
           return (
-            <th className="pvtAxisLabel" key={`rowAttr-${i}`}>
+            <th
+              key={`rowKeyLabel-${i}`}
+              className={valueCellClassName}
+              rowSpan={rowSpan}
+              colSpan={colSpan}
+              role="columnheader button"
+              onClick={clickHeaderHandler(
+                pivotData,
+                rowKey,
+                rows,
+                i,
+                tableOptions.clickRowHeaderCallback,
+              )}
+              onContextMenu={handleContextMenu}
+            >
               {displayHeaderCell(
-                needLabelToggle,
-                subArrow,
-                arrowClickHandle,
-                r,
+                needRowToggle,
+                collapsedRows[flatRowKeySlice] ? arrowCollapsed : arrowExpanded,
+                onArrowClick,
+                headerCellFormattedValue,
                 namesMapping,
-                allowRenderHtml,
+                settingsAllowRenderHtml,
               )}
             </th>
           );
-        })}
-        <th
-          className="pvtTotalLabel"
-          key="padding"
-          role="columnheader button"
-          onClick={this.clickHeaderHandler(
-            pivotData,
-            [],
-            this.props.rows,
-            0,
-            this.props.tableOptions.clickRowHeaderCallback,
-            false,
-            true,
-          )}
-        >
-          {colAttrs.length === 0
-            ? t('Total (%(aggregatorName)s)', {
-                aggregatorName: t(this.props.aggregatorName),
-              })
-            : null}
-        </th>
-      </tr>
-    );
-  }
-
-  renderTableRow(
-    rowKey: string[],
-    rowIdx: number,
-    pivotSettings: PivotSettings,
-  ) {
-    // Render a single row in the pivot table.
-
-    const {
-      rowAttrs,
-      colAttrs,
-      rowAttrSpans,
-      visibleColKeys,
-      pivotData,
-      rowTotals,
-      rowSubtotalDisplay,
-      arrowExpanded,
-      arrowCollapsed,
-      cellCallbacks,
-      rowTotalCallbacks,
-      namesMapping,
-      allowRenderHtml,
-    } = pivotSettings;
-
-    const {
-      highlightHeaderCellsOnHover,
-      omittedHighlightHeaderGroups = [],
-      highlightedHeaderCells,
-      cellColorFormatters,
-      dateFormatters,
-    } = this.props.tableOptions;
-    const flatRowKey = flatKey(rowKey);
-
-    const colIncrSpan = colAttrs.length !== 0 ? 1 : 0;
-    const attrValueCells = rowKey.map((r: string, i: number) => {
-      let handleContextMenu: ((e: MouseEvent) => void) | undefined;
-      let valueCellClassName = 'pvtRowLabel';
-      if (!omittedHighlightHeaderGroups.includes(rowAttrs[i])) {
-        if (highlightHeaderCellsOnHover) {
-          valueCellClassName += ' hoverable';
         }
-        handleContextMenu = (e: MouseEvent) =>
-          this.props.onContextMenu(e, undefined, rowKey, {
-            [rowAttrs[i]]: r,
-          });
-      }
-      if (
-        highlightedHeaderCells &&
-        Array.isArray(highlightedHeaderCells[rowAttrs[i]]) &&
-        highlightedHeaderCells[rowAttrs[i]].includes(r)
-      ) {
-        valueCellClassName += ' active';
-      }
-      const rowSpan = rowAttrSpans![rowIdx][i];
-      if (rowSpan > 0) {
-        const flatRowKey = flatKey(rowKey.slice(0, i + 1));
-        const colSpan = 1 + (i === rowAttrs.length - 1 ? colIncrSpan : 0);
-        const needRowToggle =
-          rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1;
-        const onArrowClick = needRowToggle
-          ? this.toggleRowKey(flatRowKey)
-          : null;
-
-        const headerCellFormattedValue =
-          dateFormatters?.[rowAttrs[i]]?.(r) ?? r;
-        return (
+        return null;
+      });
+
+      const attrValuePaddingCell =
+        rowKey.length < settingsRowAttrs.length ? (
           <th
-            key={`rowKeyLabel-${i}`}
-            className={valueCellClassName}
-            rowSpan={rowSpan}
-            colSpan={colSpan}
+            className="pvtRowLabel pvtSubtotalLabel"
+            key="rowKeyBuffer"
+            colSpan={settingsRowAttrs.length - rowKey.length + colIncrSpan}
+            rowSpan={1}
             role="columnheader button"
-            onClick={this.clickHeaderHandler(
+            onClick={clickHeaderHandler(
               pivotData,
               rowKey,
-              this.props.rows,
-              i,
-              this.props.tableOptions.clickRowHeaderCallback,
+              rows,
+              rowKey.length,
+              tableOptions.clickRowHeaderCallback,
+              true,
             )}
-            onContextMenu={handleContextMenu}
           >
-            {displayHeaderCell(
-              needRowToggle,
-              this.state.collapsedRows[flatRowKey]
-                ? arrowCollapsed
-                : arrowExpanded,
-              onArrowClick,
-              headerCellFormattedValue,
-              namesMapping,
-              allowRenderHtml,
-            )}
+            {t('Subtotal')}
           </th>
-        );
-      }
-      return null;
-    });
-
-    const attrValuePaddingCell =
-      rowKey.length < rowAttrs.length ? (
-        <th
-          className="pvtRowLabel pvtSubtotalLabel"
-          key="rowKeyBuffer"
-          colSpan={rowAttrs.length - rowKey.length + colIncrSpan}
-          rowSpan={1}
-          role="columnheader button"
-          onClick={this.clickHeaderHandler(
-            pivotData,
-            rowKey,
-            this.props.rows,
-            rowKey.length,
-            this.props.tableOptions.clickRowHeaderCallback,
-            true,
-          )}
-        >
-          {t('Subtotal')}
-        </th>
-      ) : null;
+        ) : null;
 
-    if (!visibleColKeys) {
-      return null;
-    }
-
-    const rowClickHandlers = cellCallbacks[flatRowKey] || {};
-    const valueCells = visibleColKeys.map((colKey: string[]) => {
-      const flatColKey = flatKey(colKey);
-      const agg = pivotData.getAggregator(rowKey, colKey);
-      const aggValue = agg.value();
-
-      const keys = [...rowKey, ...colKey];
-      let backgroundColor: string | undefined;
-      if (cellColorFormatters) {
-        Object.values(cellColorFormatters).forEach(cellColorFormatter => {
-          if (Array.isArray(cellColorFormatter)) {
-            keys.forEach(key => {
-              if (backgroundColor) {
-                return;
-              }
-              cellColorFormatter
-                .filter(formatter => formatter.column === key)
-                .forEach(formatter => {
-                  const formatterResult = formatter.getColorFromValue(aggValue);
-                  if (formatterResult) {
-                    backgroundColor = formatterResult;
-                  }
-                });
-            });
-          }
-        });
+      if (!settingsVisibleColKeys) {
+        return null;
       }
 
-      const style = agg.isSubtotal
-        ? { fontWeight: 'bold' }
-        : { backgroundColor };
-
-      return (
-        <td
-          role="gridcell"
-          className="pvtVal"
-          key={`pvtVal-${flatColKey}`}
-          onClick={rowClickHandlers[flatColKey]}
-          onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
-          style={style}
-        >
-          {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
-        </td>
-      );
-    });
-
-    let totalCell = null;
-    if (rowTotals) {
-      const agg = pivotData.getAggregator(rowKey, []);
-      const aggValue = agg.value();
-      totalCell = (
-        <td
-          role="gridcell"
-          key="total"
-          className="pvtTotal"
-          onClick={rowTotalCallbacks[flatRowKey]}
-          onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
-        >
-          {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
-        </td>
-      );
-    }
+      const rowClickHandlers = cellCallbacks[flatRowKey] || {};
+      const valueCells = settingsVisibleColKeys.map((colKey: string[]) => {
+        const flatColKey = flatKey(colKey);
+        const agg = pivotData.getAggregator(rowKey, colKey);
+        const aggValue = agg.value();
+
+        const keys = [...rowKey, ...colKey];
+        let backgroundColor: string | undefined;
+        if (cellColorFormatters) {
+          Object.values(cellColorFormatters).forEach(cellColorFormatter => {
+            if (Array.isArray(cellColorFormatter)) {
+              keys.forEach(key => {
+                if (backgroundColor) {
+                  return;
+                }
+                cellColorFormatter
+                  .filter(formatter => formatter.column === key)
+                  .forEach(formatter => {
+                    const formatterResult =
+                      formatter.getColorFromValue(aggValue);
+                    if (formatterResult) {
+                      backgroundColor = formatterResult;
+                    }
+                  });
+              });
+            }
+          });
+        }
 
-    const rowCells = [
-      ...attrValueCells,
-      attrValuePaddingCell,
-      ...valueCells,
-      totalCell,
-    ];
+        const style = agg.isSubtotal
+          ? { fontWeight: 'bold' }
+          : { backgroundColor };
 
-    return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
-  }
-
-  renderTotalsRow(pivotSettings: PivotSettings) {
-    // Render the final totals rows that has the totals for all the columns.
+        return (
+          <td
+            role="gridcell"
+            className="pvtVal"
+            key={`pvtVal-${flatColKey}`}
+            onClick={rowClickHandlers[flatColKey]}
+            onContextMenu={e => onContextMenu(e, colKey, rowKey)}
+            style={style}
+          >
+            {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
+          </td>
+        );
+      });
 
-    const {
-      rowAttrs,
-      colAttrs,
-      visibleColKeys,
-      rowTotals,
-      pivotData,
-      colTotalCallbacks,
-      grandTotalCallback,
-    } = pivotSettings;
+      let totalCell = null;
+      if (rowTotals) {
+        const agg = pivotData.getAggregator(rowKey, []);
+        const aggValue = agg.value();
+        totalCell = (
+          <td
+            role="gridcell"
+            key="total"
+            className="pvtTotal"
+            onClick={rowTotalCallbacks[flatRowKey]}
+            onContextMenu={e => onContextMenu(e, undefined, rowKey)}
+          >
+            {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
+          </td>
+        );
+      }
 
-    if (!visibleColKeys) {
-      return null;
-    }
+      const rowCells = [
+        ...attrValueCells,
+        attrValuePaddingCell,
+        ...valueCells,
+        totalCell,
+      ];
+
+      return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
+    },
+    [
+      tableOptions,
+      onContextMenu,
+      toggleRowKey,
+      clickHeaderHandler,
+      rows,
+      collapsedRows,
+    ],
+  );
 
-    const totalLabelCell = (
-      <th
-        key="label"
-        className="pvtTotalLabel pvtRowTotalLabel"
-        colSpan={rowAttrs.length + Math.min(colAttrs.length, 1)}
-        role="columnheader button"
-        onClick={this.clickHeaderHandler(
-          pivotData,
-          [],
-          this.props.rows,
-          0,
-          this.props.tableOptions.clickRowHeaderCallback,
-          false,
-          true,
-        )}
-      >
-        {t('Total (%(aggregatorName)s)', {
-          aggregatorName: t(this.props.aggregatorName),
-        })}
-      </th>
-    );
+  const renderTotalsRow = useCallback(
+    (settings: PivotSettings) => {
+      // Render the final totals rows that has the totals for all the columns.
 
-    const totalValueCells = visibleColKeys.map((colKey: string[]) => {
-      const flatColKey = flatKey(colKey);
-      const agg = pivotData.getAggregator([], colKey);
-      const aggValue = agg.value();
+      const {
+        rowAttrs: settingsRowAttrs,
+        colAttrs: settingsColAttrs,
+        visibleColKeys: settingsVisibleColKeys,
+        rowTotals,
+        pivotData,
+        colTotalCallbacks,
+        grandTotalCallback,
+      } = settings;
 
-      return (
-        <td
-          role="gridcell"
-          className="pvtTotal pvtRowTotal"
-          key={`total-${flatColKey}`}
-          onClick={colTotalCallbacks[flatColKey]}
-          onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
-          style={{ padding: '5px' }}
-        >
-          {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
-        </td>
-      );
-    });
+      if (!settingsVisibleColKeys) {
+        return null;
+      }
 
-    let grandTotalCell = null;
-    if (rowTotals) {
-      const agg = pivotData.getAggregator([], []);
-      const aggValue = agg.value();
-      grandTotalCell = (
-        <td
-          role="gridcell"
-          key="total"
-          className="pvtGrandTotal pvtRowTotal"
-          onClick={grandTotalCallback || undefined}
-          onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
+      const totalLabelCell = (
+        <th
+          key="label"
+          className="pvtTotalLabel pvtRowTotalLabel"
+          colSpan={
+            settingsRowAttrs.length + Math.min(settingsColAttrs.length, 1)
+          }
+          role="columnheader button"
+          onClick={clickHeaderHandler(
+            pivotData,
+            [],
+            rows,
+            0,
+            tableOptions.clickRowHeaderCallback,
+            false,
+            true,
+          )}
         >
-          {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
-        </td>
+          {t('Total (%(aggregatorName)s)', {
+            aggregatorName: t(aggregatorName),
+          })}
+        </th>
       );
-    }
 
-    const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
+      const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => {
+        const flatColKey = flatKey(colKey);
+        const agg = pivotData.getAggregator([], colKey);
+        const aggValue = agg.value();
 
-    return (
-      <tr key="total" className="pvtRowTotals">
-        {totalCells}
-      </tr>
-    );
-  }
-
-  visibleKeys(
-    keys: string[][],
-    collapsed: Record<string, boolean>,
-    numAttrs: number,
-    subtotalDisplay: SubtotalDisplay,
-  ) {
-    return keys.filter(
-      (key: string[]) =>
-        // Is the key hidden by one of its parents?
-        !key.some(
-          (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))],
-        ) &&
-        // Leaf key.
-        (key.length === numAttrs ||
-          // Children hidden. Must show total.
-          flatKey(key) in collapsed ||
-          // Don't hide totals.
-          !subtotalDisplay.hideOnExpand),
-    );
-  }
-
-  isDashboardEditMode() {
-    return document.contains(document.querySelector('.dashboard--editing'));
-  }
+        return (
+          <td
+            role="gridcell"
+            className="pvtTotal pvtRowTotal"
+            key={`total-${flatColKey}`}
+            onClick={colTotalCallbacks[flatColKey]}
+            onContextMenu={e => onContextMenu(e, colKey, undefined)}
+            style={{ padding: '5px' }}
+          >
+            {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
+          </td>
+        );
+      });
 
-  componentWillUnmount() {
-    this.sortCache.clear();
-  }
+      let grandTotalCell = null;
+      if (rowTotals) {
+        const agg = pivotData.getAggregator([], []);
+        const aggValue = agg.value();
+        grandTotalCell = (
+          <td
+            role="gridcell"
+            key="total"
+            className="pvtGrandTotal pvtRowTotal"
+            onClick={grandTotalCallback || undefined}
+            onContextMenu={e => onContextMenu(e, undefined, undefined)}
+          >
+            {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
+          </td>
+        );
+      }
 
-  render() {
-    if (this.cachedProps !== this.props) {
-      this.sortCache.clear();
-      // Reset sort state without using setState to avoid re-render during render.
-      // This is safe because the state is being synchronized with new props.
-      (this.state as TableRendererState).sortingOrder = [];
-      (this.state as TableRendererState).activeSortColumn = null;
-      this.cachedProps = this.props;
-      this.cachedBasePivotSettings = this.getBasePivotSettings();
-    }
-    const basePivotSettings = this.cachedBasePivotSettings!;
-    const {
-      colAttrs,
-      rowAttrs,
-      rowKeys,
-      colKeys,
-      colTotals,
-      rowSubtotalDisplay,
-      colSubtotalDisplay,
-      allowRenderHtml,
-    } = basePivotSettings;
+      const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
 
-    // Need to account for exclusions to compute the effective row
-    // and column keys.
-    const visibleRowKeys = this.visibleKeys(
-      rowKeys,
-      this.state.collapsedRows,
-      rowAttrs.length,
-      rowSubtotalDisplay,
-    );
-    const visibleColKeys = this.visibleKeys(
-      colKeys,
-      this.state.collapsedCols,
-      colAttrs.length,
-      colSubtotalDisplay,
-    );
-
-    const pivotSettings: PivotSettings = {
-      visibleRowKeys,
-      maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)),
-      visibleColKeys,
-      maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
-      rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length),
-      colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length),
+      return (
+        <tr key="total" className="pvtRowTotals">
+          {totalCells}
+        </tr>
+      );
+    },
+    [
+      clickHeaderHandler,
+      rows,
+      tableOptions.clickRowHeaderCallback,
+      aggregatorName,
+      onContextMenu,
       allowRenderHtml,
-      ...basePivotSettings,
-    };
+    ],
+  );
 
-    return (
-      <Styles isDashboardEditMode={this.isDashboardEditMode()}>
-        <table className="pvtTable" role="grid">
-          <thead>
-            {colAttrs.map((c: string, j: number) =>
-              this.renderColHeaderRow(c, j, pivotSettings),
-            )}
-            {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)}
-          </thead>
-          <tbody>
-            {visibleRowKeys.map((r: string[], i: number) =>
-              this.renderTableRow(r, i, pivotSettings),
-            )}
-            {colTotals && this.renderTotalsRow(pivotSettings)}
-          </tbody>
-        </table>
-      </Styles>
-    );
-  }
+  return (
+    <Styles isDashboardEditMode={isDashboardEditMode()}>
+      <table className="pvtTable" role="grid">
+        <thead>
+          {colAttrs.map((c: string, j: number) =>
+            renderColHeaderRow(c, j, pivotSettings),
+          )}
+          {rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)}
+        </thead>
+        <tbody>
+          {visibleRowKeys.map((r: string[], i: number) =>
+            renderTableRow(r, i, pivotSettings),
+          )}
+          {colTotals && renderTotalsRow(pivotSettings)}
+        </tbody>
+      </table>
+    </Styles>
+  );
 }
 
 TableRenderer.propTypes = {
diff --git a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
index 6d1abedb774..e3398b84bf0 100644
--- a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
+++ b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import cloudLayout from 'd3-cloud';
 import { scaleLinear } from 'd3-scale';
 import { seed, CategoricalColorNamespace } from '@superset-ui/core';
@@ -81,18 +81,7 @@ export interface WordCloudProps extends WordCloudVisualProps {
   colorScheme: string;
 }
 
-export interface WordCloudState {
-  words: Word[];
-  scaleFactor: number;
-}
-
-const defaultProps: Required<WordCloudVisualProps> = {
-  encoding: {},
-  rotation: 'flat',
-};
-
-type FullWordCloudProps = WordCloudProps &
-  typeof defaultProps & { theme: SupersetTheme };
+type FullWordCloudProps = WordCloudProps & { theme: SupersetTheme };
 
 const SCALE_FACTOR_STEP = 0.5;
 const MAX_SCALE_FACTOR = 3;
@@ -196,61 +185,80 @@ class SimpleEncoder {
   }
 }
 
-class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
-  static defaultProps = defaultProps;
-
-  isComponentMounted = false;
-
-  createEncoder = (encoding?: Partial<WordCloudEncoding>): SimpleEncoder =>
-    new SimpleEncoder(encoding ?? {}, {
-      color: this.props.theme.colorTextLabel,
-      fontFamily: this.props.theme.fontFamily,
-      fontSize: 20,
-      fontWeight: 'bold',
-      text: '',
-    });
-
-  constructor(props: FullWordCloudProps) {
-    super(props);
-    this.state = {
-      words: [],
-      scaleFactor: 1,
-    };
-    this.setWords = this.setWords.bind(this);
-  }
-
-  componentDidMount() {
-    this.isComponentMounted = true;
-    this.update();
-  }
-
-  componentDidUpdate(prevProps: WordCloudProps) {
-    const { data, encoding, width, height, rotation } = this.props;
-    if (
-      !isEqual(prevProps.data, data) ||
-      !isEqual(prevProps.encoding, encoding) ||
-      prevProps.width !== width ||
-      prevProps.height !== height ||
-      prevProps.rotation !== rotation
-    ) {
-      this.update();
-    }
-  }
-
-  componentWillUnmount() {
-    this.isComponentMounted = false;
-  }
-
-  setWords(words: Word[]) {
-    if (this.isComponentMounted) {
-      this.setState({ words });
+function WordCloud({
+  data,
+  encoding = {},
+  width,
+  height,
+  rotation = 'flat',
+  sliceId,
+  colorScheme,
+  theme,
+}: FullWordCloudProps) {
+  const [words, setWords] = useState<Word[]>([]);
+  const [scaleFactor] = useState(1);
+  const isMountedRef = useRef(true);
+
+  // Store previous props for comparison
+  const prevPropsRef = useRef<{
+    data: PlainObject[];
+    encoding: Partial<WordCloudEncoding>;
+    width: number;
+    height: number;
+    rotation: RotationType;
+  } | null>(null);
+
+  const createEncoder = useCallback(
+    (enc?: Partial<WordCloudEncoding>): SimpleEncoder =>
+      new SimpleEncoder(enc ?? {}, {
+        color: theme.colorTextLabel,
+        fontFamily: theme.fontFamily,
+        fontSize: 20,
+        fontWeight: 'bold',
+        text: '',
+      }),
+    [theme.colorTextLabel, theme.fontFamily],
+  );
+
+  const setWordsIfMounted = useCallback((newWords: Word[]) => {
+    if (isMountedRef.current) {
+      setWords(newWords);
     }
-  }
-
-  update() {
-    const { data, encoding } = this.props;
+  }, []);
+
+  const generateCloud = useCallback(
+    (
+      encoder: SimpleEncoder,
+      currentScaleFactor: number,
+      isValid: (word: Word[]) => boolean,
+    ) => {
+      cloudLayout()
+        .size([width * currentScaleFactor, height * currentScaleFactor])
+        .words(data.map((d: Word) => ({ ...d })))
+        .padding(5)
+        .rotate(ROTATION[rotation] || ROTATION.flat)
+        .text((d: PlainObject) => encoder.getText(d))
+        .font((d: PlainObject) => encoder.getFontFamily(d))
+        .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
+        .fontSize((d: PlainObject) => encoder.getFontSize(d))
+        .on('end', (cloudWords: Word[]) => {
+          if (isValid(cloudWords) || currentScaleFactor > MAX_SCALE_FACTOR) {
+            setWordsIfMounted(cloudWords);
+          } else {
+            generateCloud(
+              encoder,
+              currentScaleFactor + SCALE_FACTOR_STEP,
+              isValid,
+            );
+          }
+        })
+        .start();
+    },
+    [data, width, height, rotation, setWordsIfMounted],
+  );
 
-    const encoder = this.createEncoder(encoding);
+  const update = useCallback(() => {
+    const encoder = createEncoder(encoding);
     encoder.setDomainFromDataset(data);
 
     const sortedData = [...data].sort(
@@ -262,73 +270,71 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
     );
     const topResults = sortedData.slice(0, topResultsCount);
 
-    this.generateCloud(encoder, 1, (words: Word[]) =>
+    generateCloud(encoder, 1, (cloudWords: Word[]) =>
       topResults.every((d: PlainObject) =>
-        words.find(({ text }) => encoder.getText(d) === text),
+        cloudWords.find(({ text }) => encoder.getText(d) === text),
       ),
     );
-  }
+  }, [data, encoding, createEncoder, generateCloud]);
 
-  generateCloud(
-    encoder: SimpleEncoder,
-    scaleFactor: number,
-    isValid: (word: Word[]) => boolean,
-  ) {
-    const { data, width, height, rotation } = this.props;
-
-    cloudLayout()
-      .size([width * scaleFactor, height * scaleFactor])
-      .words(data.map((d: Word) => ({ ...d })))
-      .padding(5)
-      .rotate(ROTATION[rotation] || ROTATION.flat)
-      .text((d: PlainObject) => encoder.getText(d))
-      .font((d: PlainObject) => encoder.getFontFamily(d))
-      .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
-      .fontSize((d: PlainObject) => encoder.getFontSize(d))
-      .on('end', (words: Word[]) => {
-        if (isValid(words) || scaleFactor > MAX_SCALE_FACTOR) {
-          this.setWords(words);
-        } else {
-          this.generateCloud(encoder, scaleFactor + SCALE_FACTOR_STEP, isValid);
-        }
-      })
-      .start();
-  }
+  // Component mount/unmount tracking
+  useEffect(() => {
+    isMountedRef.current = true;
+    return () => {
+      isMountedRef.current = false;
+    };
+  }, []);
 
-  render() {
-    const { scaleFactor, words } = this.state;
-    const { width, height, encoding, sliceId, colorScheme } = this.props;
-
-    const encoder = this.createEncoder(encoding);
-
-    const colorFn = CategoricalColorNamespace.getScale(colorScheme);
-    const viewBoxWidth = width * scaleFactor;
-    const viewBoxHeight = height * scaleFactor;
-
-    return (
-      <svg
-        width={width}
-        height={height}
-        viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
-      >
-        <g>
-          {words.map(w => (
-            <text
-              key={w.text}
-              fontSize={`${w.size}px`}
-              fontWeight={w.weight}
-              fontFamily={w.font}
-              fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
-              textAnchor="middle"
-              transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
-            >
-              {w.text}
-            </text>
-          ))}
-        </g>
-      </svg>
-    );
-  }
+  // Initial update on mount and when dependencies change
+  useEffect(() => {
+    const prevProps = prevPropsRef.current;
+    const shouldUpdate =
+      !prevProps ||
+      !isEqual(prevProps.data, data) ||
+      !isEqual(prevProps.encoding, encoding) ||
+      prevProps.width !== width ||
+      prevProps.height !== height ||
+      prevProps.rotation !== rotation;
+
+    if (shouldUpdate) {
+      update();
+    }
+
+    prevPropsRef.current = { data, encoding, width, height, rotation };
+  }, [data, encoding, width, height, rotation, update]);
+
+  const encoder = useMemo(
+    () => createEncoder(encoding),
+    [createEncoder, encoding],
+  );
+
+  const colorFn = CategoricalColorNamespace.getScale(colorScheme);
+  const viewBoxWidth = width * scaleFactor;
+  const viewBoxHeight = height * scaleFactor;
+
+  return (
+    <svg
+      width={width}
+      height={height}
+      viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
+    >
+      <g>
+        {words.map(w => (
+          <text
+            key={w.text}
+            fontSize={`${w.size}px`}
+            fontWeight={w.weight}
+            fontFamily={w.font}
+            fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
+            textAnchor="middle"
+            transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
+          >
+            {w.text}
+          </text>
+        ))}
+      </g>
+    </svg>
+  );
 }
 
 export default withTheme(WordCloud);
diff --git a/superset-frontend/src/SqlLab/components/App/App.test.tsx b/superset-frontend/src/SqlLab/components/App/App.test.tsx
index 4d1f922add4..67675c59128 100644
--- a/superset-frontend/src/SqlLab/components/App/App.test.tsx
+++ b/superset-frontend/src/SqlLab/components/App/App.test.tsx
@@ -92,7 +92,7 @@ describe('SqlLab App', () => {
       useRedux: true,
       store: storeExceedLocalStorage,
     });
-    rerender(<App updated />);
+    rerender(<App />);
     expect(storeExceedLocalStorage.getActions()).toContainEqual(
       expect.objectContaining({
         type: LOG_EVENT,
@@ -118,7 +118,7 @@ describe('SqlLab App', () => {
       useRedux: true,
       store: storeExceedLocalStorage,
     });
-    rerender(<App updated />);
+    rerender(<App />);
     expect(storeExceedLocalStorage.getActions()).toContainEqual(
       expect.objectContaining({
         type: LOG_EVENT,
diff --git a/superset-frontend/src/SqlLab/components/App/index.tsx b/superset-frontend/src/SqlLab/components/App/index.tsx
index 83200616524..337ab1a870b 100644
--- a/superset-frontend/src/SqlLab/components/App/index.tsx
+++ b/superset-frontend/src/SqlLab/components/App/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { connect } from 'react-redux';
 import { Redirect } from 'react-router-dom';
 import Mousetrap from 'mousetrap';
@@ -103,59 +103,85 @@ const SqlLabStyles = styled.div`
   `};
 `;
 
-type PureProps = {
-  // add this for testing componentDidUpdate spec
-  updated?: boolean;
-};
-
-type AppProps = ReturnType<typeof mergeProps> & PureProps;
-
-interface AppState {
-  hash: string;
-}
-
-class App extends PureComponent<AppProps, AppState> {
-  hasLoggedLocalStorageUsage: boolean;
-
-  private boundOnHashChanged: () => void;
-
-  constructor(props: AppProps) {
-    super(props);
-    this.state = {
-      hash: window.location.hash,
-    };
-
-    this.boundOnHashChanged = this.onHashChanged.bind(this);
+type AppProps = ReturnType<typeof mergeProps>;
+
+function App({
+  actions,
+  localStorageUsageInKilobytes,
+  queries,
+  queriesLastUpdate,
+}: AppProps) {
+  const [hash, setHash] = useState(window.location.hash);
+  const hasLoggedLocalStorageUsageRef = useRef(false);
+
+  const showLocalStorageUsageWarning = useMemo(
+    () =>
+      throttle(
+        (currentUsage: number, queryCount: number) => {
+          actions.addDangerToast(
+            t(
+              "SQL Lab uses your browser's local storage to store queries and results." +
+                '\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
+                '\nTo keep SQL Lab from crashing, please delete some query tabs.' +
+                '\nYou can re-access these queries by using the Save feature before you delete the tab.' +
+                '\nNote that you will need to close other SQL Lab windows before you do this.',
+              {
+                currentUsage: currentUsage.toFixed(2),
+                maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
+              },
+            ),
+          );
+          const eventData = {
+            current_usage: currentUsage,
+            query_count: queryCount,
+          };
+          actions.logEvent(
+            LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
+            eventData,
+          );
+        },
+        LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
+        { trailing: false },
+      ),
+    [actions],
+  );
 
-    this.showLocalStorageUsageWarning = throttle(
-      this.showLocalStorageUsageWarning,
-      LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
-      { trailing: false },
-    );
-  }
+  const onHashChanged = useCallback(() => {
+    setHash(window.location.hash);
+  }, []);
 
-  componentDidMount() {
-    window.addEventListener('hashchange', this.boundOnHashChanged);
+  // componentDidMount and componentWillUnmount
+  useEffect(() => {
+    window.addEventListener('hashchange', onHashChanged);
 
     // Horrible hack to disable side swipe navigation when in SQL Lab. Even though the
     // docs say setting this style on any div will prevent it, turns out it only works
     // when set on the body element.
     document.body.style.overscrollBehaviorX = 'none';
-  }
 
-  componentDidUpdate() {
-    const { localStorageUsageInKilobytes, actions, queries } = this.props;
+    return () => {
+      window.removeEventListener('hashchange', onHashChanged);
+
+      // And we need to reset the overscroll behavior back to the default.
+      document.body.style.overscrollBehaviorX = 'auto';
+
+      Mousetrap.reset();
+    };
+  }, [onHashChanged]);
+
+  // componentDidUpdate - check local storage usage
+  useEffect(() => {
     const queryCount = Object.keys(queries || {}).length || 0;
     if (
       localStorageUsageInKilobytes >=
       LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB
     ) {
-      this.showLocalStorageUsageWarning(
-        localStorageUsageInKilobytes,
-        queryCount,
-      );
+      showLocalStorageUsageWarning(localStorageUsageInKilobytes, queryCount);
     }
-    if (localStorageUsageInKilobytes > 0 && !this.hasLoggedLocalStorageUsage) {
+    if (
+      localStorageUsageInKilobytes > 0 &&
+      !hasLoggedLocalStorageUsageRef.current
+    ) {
       const eventData = {
         current_usage: localStorageUsageInKilobytes,
         query_count: queryCount,
@@ -164,72 +190,38 @@ class App extends PureComponent<AppProps, AppState> {
         LOG_ACTIONS_SQLLAB_MONITOR_LOCAL_STORAGE_USAGE,
         eventData,
       );
-      this.hasLoggedLocalStorageUsage = true;
+      hasLoggedLocalStorageUsageRef.current = true;
     }
-  }
-
-  componentWillUnmount() {
-    window.removeEventListener('hashchange', this.boundOnHashChanged);
-
-    // And now we need to reset the overscroll behavior back to the default.
-    document.body.style.overscrollBehaviorX = 'auto';
-
-    Mousetrap.reset();
-  }
-
-  onHashChanged() {
-    this.setState({ hash: window.location.hash });
-  }
-
-  showLocalStorageUsageWarning(currentUsage: number, queryCount: number) {
-    this.props.actions.addDangerToast(
-      t(
-        "SQL Lab uses your browser's local storage to store queries and results." +
-          '\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
-          '\nTo keep SQL Lab from crashing, please delete some query tabs.' +
-          '\nYou can re-access these queries by using the Save feature before you delete the tab.' +
-          '\nNote that you will need to close other SQL Lab windows before you do this.',
-        {
-          currentUsage: currentUsage.toFixed(2),
-          maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
-        },
-      ),
-    );
-    const eventData = {
-      current_usage: currentUsage,
-      query_count: queryCount,
-    };
-    this.props.actions.logEvent(
-      LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
-      eventData,
-    );
-  }
+  }, [
+    localStorageUsageInKilobytes,
+    queries,
+    actions,
+    showLocalStorageUsageWarning,
+  ]);
 
-  render() {
-    const { queries, queriesLastUpdate } = this.props;
-    if (this.state.hash && this.state.hash === '#search') {
-      return (
-        <Redirect
-          to={{
-            pathname: '/sqllab/history/',
-          }}
-        />
-      );
-    }
+  if (hash && hash === '#search') {
     return (
-      <SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
-        <QueryAutoRefresh
-          queries={queries}
-          queriesLastUpdate={queriesLastUpdate}
-        />
-        <PopEditorTab>
-          <AppLayout>
-            <TabbedSqlEditors />
-          </AppLayout>
-        </PopEditorTab>
-      </SqlLabStyles>
+      <Redirect
+        to={{
+          pathname: '/sqllab/history/',
+        }}
+      />
     );
   }
+
+  return (
+    <SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
+      <QueryAutoRefresh
+        queries={queries}
+        queriesLastUpdate={queriesLastUpdate}
+      />
+      <PopEditorTab>
+        <AppLayout>
+          <TabbedSqlEditors />
+        </AppLayout>
+      </PopEditorTab>
+    </SqlLabStyles>
+  );
 }
 
 function mapStateToProps(state: SqlLabRootState) {
@@ -250,10 +242,8 @@ const mapDispatchToProps = {
 function mergeProps(
   stateProps: ReturnType<typeof mapStateToProps>,
   dispatchProps: typeof mapDispatchToProps,
-  state: PureProps,
 ) {
   return {
-    ...state,
     ...stateProps,
     actions: dispatchProps,
   };
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
index 83c870e1233..d20af45e5e0 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { useEffect, useCallback, useMemo, useRef } from 'react';
 import { EditableTabs } from '@superset-ui/core/components/Tabs';
 import { connect } from 'react-redux';
 import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -32,10 +32,10 @@ import SqlEditor from '../SqlEditor';
 import SqlEditorTabHeader from '../SqlEditorTabHeader';
 
 const DEFAULT_PROPS = {
-  queryEditors: [],
+  queryEditors: [] as QueryEditor[],
   offline: false,
-  saveQueryWarning: null,
-  scheduleQueryWarning: null,
+  saveQueryWarning: null as string | null,
+  scheduleQueryWarning: null as string | null,
 };
 
 const StyledEditableTabs = styled(EditableTabs)`
@@ -94,166 +94,192 @@ const userOS = detectOS();
 
 type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
 
-class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
-  constructor(props: TabbedSqlEditorsProps) {
-    super(props);
-    this.removeQueryEditor = this.removeQueryEditor.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.handleEdit = this.handleEdit.bind(this);
-  }
+function TabbedSqlEditors({
+  actions,
+  queryEditors = DEFAULT_PROPS.queryEditors,
+  queries,
+  tabHistory,
+  displayLimit,
+  offline = DEFAULT_PROPS.offline,
+  defaultQueryLimit,
+  maxRow,
+  saveQueryWarning = DEFAULT_PROPS.saveQueryWarning,
+  scheduleQueryWarning = DEFAULT_PROPS.scheduleQueryWarning,
+}: TabbedSqlEditorsProps) {
+  const activeQueryEditor = useMemo(() => {
+    if (tabHistory.length === 0) {
+      return queryEditors[0];
+    }
+    const qeid = tabHistory[tabHistory.length - 1];
+    return queryEditors.find(qe => qe.id === qeid) || null;
+  }, [tabHistory, queryEditors]);
+
+  // Track whether the initial mount effect has run
+  const hasRunInitialEffect = useRef(false);
+
+  // Fetch query results on initial mount if needed (equivalent to componentDidMount)
+  useEffect(() => {
+    if (hasRunInitialEffect.current) {
+      return;
+    }
+    hasRunInitialEffect.current = true;
 
-  componentDidMount() {
-    const qe = this.activeQueryEditor();
-    const latestQuery = this.props.queries[qe?.latestQueryId || ''];
+    const latestQuery = queries[activeQueryEditor?.latestQueryId || ''];
     if (
       isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
       latestQuery?.resultsKey
     ) {
       // when results are not stored in localStorage they need to be
       // fetched from the results backend (if configured)
-      this.props.actions.fetchQueryResults(
-        latestQuery,
-        this.props.displayLimit,
-      );
+      actions.fetchQueryResults(latestQuery, displayLimit);
     }
-  }
+  }, [queries, activeQueryEditor, actions, displayLimit]);
 
-  activeQueryEditor() {
-    if (this.props.tabHistory.length === 0) {
-      return this.props.queryEditors[0];
-    }
-    const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
-    return this.props.queryEditors.find(qe => qe.id === qeid) || null;
-  }
+  const newQueryEditor = useCallback(() => {
+    actions.addNewQueryEditor();
+  }, [actions]);
 
-  newQueryEditor() {
-    this.props.actions.addNewQueryEditor();
-  }
+  const removeQueryEditor = useCallback(
+    (qe: QueryEditor) => {
+      actions.removeQueryEditor(qe);
+    },
+    [actions],
+  );
 
-  handleSelect(key: string) {
-    const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
-    if (key !== qeid) {
-      const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
-      if (!queryEditor) {
-        return;
+  const handleSelect = useCallback(
+    (key: string) => {
+      const qeid = tabHistory[tabHistory.length - 1];
+      if (key !== qeid) {
+        const queryEditor = queryEditors.find(qe => qe.id === key);
+        if (!queryEditor) {
+          return;
+        }
+        actions.setActiveQueryEditor(queryEditor);
       }
-      this.props.actions.setActiveQueryEditor(queryEditor);
-    }
-  }
+    },
+    [tabHistory, queryEditors, actions],
+  );
 
-  handleEdit(key: string, action: string) {
-    if (action === 'remove') {
-      const qe = this.props.queryEditors.find(qe => qe.id === key);
-      if (qe) {
-        this.removeQueryEditor(qe);
+  const handleEdit = useCallback(
+    (key: string, action: string) => {
+      if (action === 'remove') {
+        const qe = queryEditors.find(qe => qe.id === key);
+        if (qe) {
+          removeQueryEditor(qe);
+        }
       }
-    }
-    if (action === 'add') {
-      Logger.markTimeOrigin();
-      this.newQueryEditor();
-    }
-  }
-
-  removeQueryEditor(qe: QueryEditor) {
-    this.props.actions.removeQueryEditor(qe);
-  }
+      if (action === 'add') {
+        Logger.markTimeOrigin();
+        newQueryEditor();
+      }
+    },
+    [queryEditors, removeQueryEditor, newQueryEditor],
+  );
 
-  onTabClicked = () => {
+  const onTabClicked = useCallback(() => {
     Logger.markTimeOrigin();
-    const noQueryEditors = this.props.queryEditors?.length === 0;
+    const noQueryEditors = queryEditors?.length === 0;
     if (noQueryEditors) {
-      this.newQueryEditor();
+      newQueryEditor();
     }
-  };
+  }, [queryEditors, newQueryEditor]);
 
-  render() {
-    const editors = this.props.queryEditors?.map(qe => ({
-      key: qe.id,
-      label: <SqlEditorTabHeader queryEditor={qe} />,
-      children: (
-        <SqlEditor
-          queryEditor={qe}
-          defaultQueryLimit={this.props.defaultQueryLimit}
-          maxRow={this.props.maxRow}
-          displayLimit={this.props.displayLimit}
-          saveQueryWarning={this.props.saveQueryWarning}
-          scheduleQueryWarning={this.props.scheduleQueryWarning}
+  const editors = useMemo(
+    () =>
+      queryEditors?.map(qe => ({
+        key: qe.id,
+        label: <SqlEditorTabHeader queryEditor={qe} />,
+        children: (
+          <SqlEditor
+            queryEditor={qe}
+            defaultQueryLimit={defaultQueryLimit}
+            maxRow={maxRow}
+            displayLimit={displayLimit}
+            saveQueryWarning={saveQueryWarning}
+            scheduleQueryWarning={scheduleQueryWarning}
+          />
+        ),
+      })),
+    [
+      queryEditors,
+      defaultQueryLimit,
+      maxRow,
+      displayLimit,
+      saveQueryWarning,
+      scheduleQueryWarning,
+    ],
+  );
+
+  const emptyTab = (
+    <StyledTab>
+      <TabTitle>{t('Add a new tab')}</TabTitle>
+      <Tooltip
+        id="add-tab"
+        placement="bottom"
+        title={
+          userOS === 'Windows'
+            ? t('New tab (Ctrl + q)')
+            : t('New tab (Ctrl + t)')
+        }
+      >
+        <Icons.PlusCircleOutlined
+          iconSize="s"
+          css={css`
+            vertical-align: middle;
+          `}
+          data-test="add-tab-icon"
         />
-      ),
-    }));
+      </Tooltip>
+    </StyledTab>
+  );
+
+  const emptyTabState = {
+    key: '0',
+    label: emptyTab,
+    children: (
+      <EmptyState
+        image="empty_sql_chart.svg"
+        size="large"
+        description={t('Add a new tab to create SQL Query')}
+      />
+    ),
+  };
+
+  const tabItems = queryEditors?.length > 0 ? editors : [emptyTabState];
 
-    const emptyTab = (
-      <StyledTab>
-        <TabTitle>{t('Add a new tab')}</TabTitle>
+  return (
+    <StyledEditableTabs
+      activeKey={tabHistory[tabHistory.length - 1]}
+      id="a11y-query-editor-tabs"
+      className="SqlEditorTabs"
+      data-test="sql-editor-tabs"
+      onChange={handleSelect}
+      hideAdd={offline}
+      onTabClick={onTabClicked}
+      onEdit={handleEdit}
+      type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
+      addIcon={
         <Tooltip
           id="add-tab"
-          placement="bottom"
+          placement="left"
           title={
             userOS === 'Windows'
               ? t('New tab (Ctrl + q)')
               : t('New tab (Ctrl + t)')
           }
         >
-          <Icons.PlusCircleOutlined
-            iconSize="s"
+          <Icons.PlusOutlined
+            iconSize="l"
             css={css`
               vertical-align: middle;
             `}
             data-test="add-tab-icon"
           />
         </Tooltip>
-      </StyledTab>
-    );
-
-    const emptyTabState = {
-      key: '0',
-      label: emptyTab,
-      children: (
-        <EmptyState
-          image="empty_sql_chart.svg"
-          size="large"
-          description={t('Add a new tab to create SQL Query')}
-        />
-      ),
-    };
-
-    const tabItems =
-      this.props.queryEditors?.length > 0 ? editors : [emptyTabState];
-
-    return (
-      <StyledEditableTabs
-        activeKey={this.props.tabHistory[this.props.tabHistory.length - 1]}
-        id="a11y-query-editor-tabs"
-        className="SqlEditorTabs"
-        data-test="sql-editor-tabs"
-        onChange={this.handleSelect}
-        hideAdd={this.props.offline}
-        onTabClick={this.onTabClicked}
-        onEdit={this.handleEdit}
-        type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
-        addIcon={
-          <Tooltip
-            id="add-tab"
-            placement="left"
-            title={
-              userOS === 'Windows'
-                ? t('New tab (Ctrl + q)')
-                : t('New tab (Ctrl + t)')
-            }
-          >
-            <Icons.PlusOutlined
-              iconSize="l"
-              css={css`
-                vertical-align: middle;
-              `}
-              data-test="add-tab-icon"
-            />
-          </Tooltip>
-        }
-        items={tabItems}
-      />
-    );
-  }
+      }
+      items={tabItems}
+    />
+  );
 }
 
 export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {
diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index 9a05bb270fb..bb3d083f1b7 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 import { t, logging } from '@apache-superset/core';
 import {
   ensureIsArray,
@@ -121,19 +121,6 @@ const NONEXISTENT_DATASET = t(
   'The dataset associated with this chart no longer exists',
 );
 
-const defaultProps: Partial<ChartProps> = {
-  addFilter: () => BLANK,
-  onFilterMenuOpen: () => BLANK,
-  onFilterMenuClose: () => BLANK,
-  initialValues: BLANK,
-  setControlValue: () => BLANK,
-  triggerRender: false,
-  dashboardId: undefined,
-  chartStackTrace: undefined,
-  force: false,
-  isInView: true,
-};
-
 const Styles = styled.div<{ height: number; width?: number }>`
   min-height: ${p => p.height}px;
   position: relative;
@@ -176,243 +163,310 @@ const MessageSpan = styled.span`
   color: ${({ theme }) => theme.colorText};
 `;
 
-class Chart extends PureComponent<ChartProps, {}> {
-  static defaultProps = defaultProps;
-
-  renderStartTime: any;
-
-  constructor(props: ChartProps) {
-    super(props);
-    this.handleRenderContainerFailure =
-      this.handleRenderContainerFailure.bind(this);
-  }
-
-  componentDidMount() {
-    if (this.props.triggerQuery) {
-      this.runQuery();
-    }
-  }
-
-  componentDidUpdate() {
-    if (this.props.triggerQuery) {
-      this.runQuery();
-    }
-  }
-
-  shouldRenderChart() {
-    return (
-      this.props.isInView ||
+function Chart({
+  addFilter = () => BLANK,
+  onFilterMenuOpen = () => BLANK,
+  onFilterMenuClose = () => BLANK,
+  initialValues = BLANK,
+  setControlValue = () => BLANK,
+  triggerRender = false,
+  dashboardId,
+  chartStackTrace,
+  force = false,
+  isInView = true,
+  ...restProps
+}: ChartProps): JSX.Element {
+  const {
+    actions,
+    chartId,
+    datasource,
+    formData,
+    timeout,
+    ownState,
+    chartAlert,
+    chartStatus,
+    queriesResponse = [],
+    errorMessage,
+    chartIsStale,
+    width,
+    height,
+    datasetsStatus,
+    onQuery,
+    annotationData,
+    labelColors: _labelColors,
+    sharedLabelColors: _sharedLabelColors,
+    vizType,
+    isFiltersInitialized: _isFiltersInitialized,
+    latestQueryFormData,
+    triggerQuery,
+    postTransformProps,
+    emitCrossFilters,
+    onChartStateChange,
+  } = restProps;
+
+  const renderStartTimeRef = useRef<number>(0);
+
+  const shouldRenderChart = useCallback(
+    () =>
+      isInView ||
       !isFeatureEnabled(FeatureFlag.DashboardVirtualization) ||
-      isCurrentUserBot()
-    );
-  }
+      isCurrentUserBot(),
+    [isInView],
+  );
 
-  runQuery() {
+  const runQuery = useCallback(() => {
     if (
       isFeatureEnabled(FeatureFlag.DashboardVirtualizationDeferData) &&
-      !this.shouldRenderChart()
+      !shouldRenderChart()
     ) {
       return;
     }
     // Create chart with POST request
-    this.props.actions.postChartFormData(
-      this.props.formData,
-      Boolean(this.props.force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
-      this.props.timeout,
-      this.props.chartId,
-      this.props.dashboardId,
-      this.props.ownState,
-    );
-  }
-
-  handleRenderContainerFailure(
-    error: Error,
-    info: { componentStack: string } | null,
-  ) {
-    const { actions, chartId } = this.props;
-    logging.warn(error);
-    actions.chartRenderingFailed(
-      error.toString(),
+    actions.postChartFormData(
+      formData,
+      Boolean(force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
+      timeout,
       chartId,
-      info ? info.componentStack : null,
+      dashboardId,
+      ownState,
     );
+  }, [
+    actions,
+    chartId,
+    dashboardId,
+    formData,
+    force,
+    ownState,
+    shouldRenderChart,
+    timeout,
+  ]);
+
+  const handleRenderContainerFailure = useCallback(
+    (error: Error, info: { componentStack: string } | null) => {
+      logging.warn(error);
+      actions.chartRenderingFailed(
+        error.toString(),
+        chartId,
+        info ? info.componentStack : null,
+      );
 
-    actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
-      slice_id: chartId,
-      has_err: true,
-      error_details: error.toString(),
-      start_offset: this.renderStartTime,
-      ts: new Date().getTime(),
-      duration: Logger.getTimestamp() - this.renderStartTime,
-    });
-  }
+      actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+        slice_id: chartId,
+        has_err: true,
+        error_details: error.toString(),
+        start_offset: renderStartTimeRef.current,
+        ts: new Date().getTime(),
+        duration: Logger.getTimestamp() - renderStartTimeRef.current,
+      });
+    },
+    [actions, chartId],
+  );
 
-  renderErrorMessage(queryResponse: ChartErrorType) {
-    const {
-      chartId,
+  // componentDidMount and componentDidUpdate combined
+  useEffect(() => {
+    if (triggerQuery) {
+      runQuery();
+    }
+  }, [triggerQuery, runQuery]);
+
+  const renderErrorMessage = useCallback(
+    (queryResponse: ChartErrorType) => {
+      const error = queryResponse?.errors?.[0];
+      const message = chartAlert || queryResponse?.message;
+
+      // if datasource is still loading, don't render JS errors
+      if (
+        chartAlert !== undefined &&
+        chartAlert !== NONEXISTENT_DATASET &&
+        datasource === PLACEHOLDER_DATASOURCE &&
+        datasetsStatus !== ResourceStatus.Error
+      ) {
+        return (
+          <Styles
+            key={chartId}
+            data-ui-anchor="chart"
+            className="chart-container"
+            data-test="chart-container"
+            height={height}
+          >
+            <Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
+          </Styles>
+        );
+      }
+
+      return (
+        <ChartErrorMessage
+          key={chartId}
+          chartId={chartId}
+          error={error}
+          subtitle={message}
+          link={queryResponse ? queryResponse.link : undefined}
+          source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
+          stackTrace={chartStackTrace}
+        />
+      );
+    },
+    [
       chartAlert,
+      chartId,
       chartStackTrace,
-      datasource,
       dashboardId,
-      height,
       datasetsStatus,
-    } = this.props;
-    const error = queryResponse?.errors?.[0];
-    const message = chartAlert || queryResponse?.message;
+      datasource,
+      height,
+    ],
+  );
+
+  const renderSpinner = useCallback(
+    (databaseName: string | undefined) => {
+      const message = databaseName
+        ? t('Waiting on %s', databaseName)
+        : t('Waiting on database...');
 
-    // if datasource is still loading, don't render JS errors
-    if (
-      chartAlert !== undefined &&
-      chartAlert !== NONEXISTENT_DATASET &&
-      datasource === PLACEHOLDER_DATASOURCE &&
-      datasetsStatus !== ResourceStatus.Error
-    ) {
       return (
-        <Styles
-          key={chartId}
-          data-ui-anchor="chart"
-          className="chart-container"
-          data-test="chart-container"
-          height={height}
-        >
+        <LoadingDiv>
           <Loading
-            size={this.props.dashboardId ? 's' : 'm'}
-            muted={!!this.props.dashboardId}
+            position="inline-centered"
+            size={dashboardId ? 's' : 'm'}
+            muted={!!dashboardId}
           />
-        </Styles>
+          <MessageSpan>{message}</MessageSpan>
+        </LoadingDiv>
       );
-    }
-
-    return (
-      <ChartErrorMessage
-        key={chartId}
-        chartId={chartId}
-        error={error}
-        subtitle={message}
-        link={queryResponse ? queryResponse.link : undefined}
-        source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
-        stackTrace={chartStackTrace}
-      />
-    );
-  }
-
-  renderSpinner(databaseName: string | undefined) {
-    const message = databaseName
-      ? t('Waiting on %s', databaseName)
-      : t('Waiting on database...');
-
-    return (
-      <LoadingDiv>
-        <Loading
-          position="inline-centered"
-          size={this.props.dashboardId ? 's' : 'm'}
-          muted={!!this.props.dashboardId}
-        />
-        <MessageSpan>{message}</MessageSpan>
-      </LoadingDiv>
-    );
-  }
+    },
+    [dashboardId],
+  );
 
-  renderChartContainer() {
-    return (
+  const renderChartContainer = useCallback(
+    () => (
       <div className="slice_container" data-test="slice-container">
-        {this.shouldRenderChart() ? (
+        {shouldRenderChart() ? (
           <ChartRenderer
-            {...this.props}
-            source={
-              this.props.dashboardId
-                ? ChartSource.Dashboard
-                : ChartSource.Explore
-            }
-            data-test={this.props.vizType}
+            annotationData={annotationData}
+            actions={actions}
+            chartId={chartId}
+            datasource={datasource}
+            initialValues={initialValues}
+            formData={formData}
+            height={height}
+            width={width}
+            setControlValue={setControlValue}
+            vizType={vizType}
+            triggerRender={triggerRender}
+            chartAlert={chartAlert}
+            chartStatus={chartStatus}
+            queriesResponse={queriesResponse}
+            triggerQuery={triggerQuery}
+            chartIsStale={chartIsStale}
+            addFilter={addFilter}
+            onFilterMenuOpen={onFilterMenuOpen}
+            onFilterMenuClose={onFilterMenuClose}
+            ownState={ownState}
+            postTransformProps={postTransformProps}
+            emitCrossFilters={emitCrossFilters}
+            onChartStateChange={onChartStateChange}
+            latestQueryFormData={latestQueryFormData}
+            source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
+            data-test={vizType}
           />
         ) : (
-          <Loading
-            size={this.props.dashboardId ? 's' : 'm'}
-            muted={!!this.props.dashboardId}
-          />
+          <Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
         )}
       </div>
-    );
-  }
-
-  render() {
-    const {
-      height,
+    ),
+    [
+      actions,
+      addFilter,
+      annotationData,
       chartAlert,
+      chartId,
+      chartIsStale,
       chartStatus,
+      dashboardId,
       datasource,
-      errorMessage,
-      chartIsStale,
-      queriesResponse = [],
+      emitCrossFilters,
+      formData,
+      height,
+      initialValues,
+      latestQueryFormData,
+      onChartStateChange,
+      onFilterMenuClose,
+      onFilterMenuOpen,
+      ownState,
+      postTransformProps,
+      queriesResponse,
+      setControlValue,
+      shouldRenderChart,
+      triggerQuery,
+      triggerRender,
+      vizType,
       width,
-    } = this.props;
+    ],
+  );
 
-    const databaseName = datasource?.database?.name as string | undefined;
+  const databaseName = datasource?.database?.name as string | undefined;
 
-    const isLoading = chartStatus === 'loading';
+  const isLoading = chartStatus === 'loading';
 
-    if (chartStatus === 'failed') {
-      return queriesResponse?.map(item =>
-        this.renderErrorMessage(item as ChartErrorType),
-      );
-    }
-
-    if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
-      return (
-        <EmptyState
-          size="large"
-          title={t('Add required control values to preview chart')}
-          description={getChartRequiredFieldsMissingMessage(true)}
-          image="chart.svg"
-        />
-      );
-    }
-    if (
-      !isLoading &&
-      !chartAlert &&
-      !errorMessage &&
-      chartIsStale &&
-      ensureIsArray(queriesResponse).length === 0
-    ) {
-      return (
-        <EmptyState
-          size="large"
-          title={t('Your chart is ready to go!')}
-          description={
-            <span>
-              {t(
-                'Click on "Create chart" button in the control panel on the left to preview a visualization or',
-              )}{' '}
-              <span role="button" tabIndex={0} onClick={this.props.onQuery}>
-                {t('click here')}
-              </span>
-              .
-            </span>
-          }
-          image="chart.svg"
-        />
-      );
-    }
+  if (chartStatus === 'failed') {
+    return (
+      <>
+        {queriesResponse?.map(item =>
+          renderErrorMessage(item as ChartErrorType),
+        )}
+      </>
+    );
+  }
 
+  if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
     return (
-      <ErrorBoundary
-        onError={this.handleRenderContainerFailure}
-        showMessage={false}
-      >
-        <Styles
-          data-ui-anchor="chart"
-          className="chart-container"
-          data-test="chart-container"
-          height={height}
-          width={width}
-        >
-          {isLoading
-            ? this.renderSpinner(databaseName)
-            : this.renderChartContainer()}
-        </Styles>
-      </ErrorBoundary>
+      <EmptyState
+        size="large"
+        title={t('Add required control values to preview chart')}
+        description={getChartRequiredFieldsMissingMessage(true)}
+        image="chart.svg"
+      />
     );
   }
+  if (
+    !isLoading &&
+    !chartAlert &&
+    !errorMessage &&
+    chartIsStale &&
+    ensureIsArray(queriesResponse).length === 0
+  ) {
+    return (
+      <EmptyState
+        size="large"
+        title={t('Your chart is ready to go!')}
+        description={
+          <span>
+            {t(
+              'Click on "Create chart" button in the control panel on the left to preview a visualization or',
+            )}{' '}
+            <span role="button" tabIndex={0} onClick={onQuery}>
+              {t('click here')}
+            </span>
+            .
+          </span>
+        }
+        image="chart.svg"
+      />
+    );
+  }
+
+  return (
+    <ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
+      <Styles
+        data-ui-anchor="chart"
+        className="chart-container"
+        data-test="chart-container"
+        height={height}
+        width={width}
+      >
+        {isLoading ? renderSpinner(databaseName) : renderChartContainer()}
+      </Styles>
+    </ErrorBoundary>
+  );
 }
 export default Chart;
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx
index 2b6016c1532..e28f8b335e6 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.tsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx
@@ -17,7 +17,16 @@
  * under the License.
  */
 import { snakeCase, isEqual, cloneDeep } from 'lodash';
-import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
+import {
+  createRef,
+  useCallback,
+  useState,
+  useRef,
+  useMemo,
+  MouseEvent,
+  ReactNode,
+  memo,
+} from 'react';
 import {
   SuperChart,
   Behavior,
@@ -136,14 +145,6 @@ export interface ChartRendererProps {
   onChartStateChange?: (chartState: AgGridChartState) => void;
 }
 
-// State interface
-interface ChartRendererState {
-  showContextMenu: boolean;
-  inContextMenu: boolean;
-  legendState: LegendState | undefined;
-  legendIndex: number;
-}
-
 // Hooks interface
 interface ChartHooks {
   onAddFilter: (
@@ -174,105 +175,258 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
 
 const behaviors = [Behavior.InteractiveChart];
 
-const defaultProps: Partial<ChartRendererProps> = {
-  addFilter: () => BLANK,
-  onFilterMenuOpen: () => BLANK,
-  onFilterMenuClose: () => BLANK,
-  initialValues: BLANK,
-  setControlValue: () => {},
-  triggerRender: false,
-};
-
-class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
-  static defaultProps = defaultProps;
-
-  private hasQueryResponseChange: boolean;
-
-  private contextMenuRef: RefObject<ChartContextMenuRef>;
-
-  private hooks: ChartHooks;
-
-  private mutableQueriesResponse: QueryData[] | null | undefined;
-
-  private renderStartTime: number;
-
-  constructor(props: ChartRendererProps) {
-    super(props);
-    const suppressContextMenu = getChartMetadataRegistry().get(
-      props.formData.viz_type ?? props.vizType,
-    )?.suppressContextMenu;
-    this.state = {
-      showContextMenu:
-        props.source === ChartSource.Dashboard &&
-        !suppressContextMenu &&
-        isFeatureEnabled(FeatureFlag.DrillToDetail),
-      inContextMenu: false,
-      legendState: undefined,
-      legendIndex: 0,
-    };
-    this.hasQueryResponseChange = false;
-    this.renderStartTime = 0;
-
-    this.contextMenuRef = createRef<ChartContextMenuRef>();
-
-    this.handleAddFilter = this.handleAddFilter.bind(this);
-    this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
-    this.handleRenderFailure = this.handleRenderFailure.bind(this);
-    this.handleSetControlValue = this.handleSetControlValue.bind(this);
-    this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
-    this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
-    this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
-    this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
-    this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
-    this.handleLegendScroll = this.handleLegendScroll.bind(this);
-
-    this.hooks = {
-      onAddFilter: this.handleAddFilter,
-      onContextMenu: this.state.showContextMenu
-        ? this.handleOnContextMenu
-        : undefined,
-      onError: this.handleRenderFailure,
-      setControlValue: this.handleSetControlValue,
-      onFilterMenuOpen: this.props.onFilterMenuOpen,
-      onFilterMenuClose: this.props.onFilterMenuClose,
-      onLegendStateChanged: this.handleLegendStateChanged,
-      setDataMask: (dataMask: DataMask) => {
-        this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
-      },
-      onLegendScroll: this.handleLegendScroll,
-      onChartStateChange: this.props.onChartStateChange,
-    };
-
-    // TODO: queriesResponse comes from Redux store but it's being edited by
-    // the plugins, hence we need to clone it to avoid state mutation
-    // until we change the reducers to use Redux Toolkit with Immer
-    this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
-  }
+interface ChartRendererState {
+  showContextMenu: boolean;
+  inContextMenu: boolean;
+  legendState: LegendState | undefined;
+  legendIndex: number;
+}
+
+interface PrevPropsRef {
+  queriesResponse: QueryData[] | null | undefined;
+  datasource: Datasource | undefined;
+  annotationData: AnnotationData | undefined;
+  ownState: OwnState | undefined;
+  filterState: FilterState | undefined;
+  height: number | undefined;
+  width: number | undefined;
+  triggerRender: boolean;
+  labelsColor: Record<string, string> | undefined;
+  labelsColorMap: Record<string, string> | undefined;
+  formData: QueryFormData;
+  cacheBusterProp: string | undefined;
+  emitCrossFilters: boolean | undefined;
+  postTransformProps: ((props: JsonObject) => JsonObject) | undefined;
+}
+
+function ChartRendererComponent({
+  addFilter = () => BLANK,
+  onFilterMenuOpen = () => BLANK,
+  onFilterMenuClose = () => BLANK,
+  initialValues = BLANK,
+  setControlValue = () => {},
+  triggerRender = false,
+  ...restProps
+}: ChartRendererProps): JSX.Element | null {
+  const {
+    annotationData,
+    actions,
+    chartId,
+    datasource,
+    formData,
+    latestQueryFormData,
+    labelsColor,
+    labelsColorMap,
+    height,
+    width,
+    vizType: propVizType,
+    chartAlert,
+    chartStatus,
+    queriesResponse,
+    chartIsStale,
+    ownState,
+    filterState,
+    postTransformProps,
+    source,
+    emitCrossFilters,
+    cacheBusterProp,
+    onChartStateChange,
+  } = restProps;
+
+  const suppressContextMenu = getChartMetadataRegistry().get(
+    formData.viz_type ?? propVizType,
+  )?.suppressContextMenu;
+
+  const [state, setState] = useState<ChartRendererState>({
+    showContextMenu:
+      source === ChartSource.Dashboard &&
+      !suppressContextMenu &&
+      isFeatureEnabled(FeatureFlag.DrillToDetail),
+    inContextMenu: false,
+    legendState: undefined,
+    legendIndex: 0,
+  });
+
+  const hasQueryResponseChangeRef = useRef(false);
+  const renderStartTimeRef = useRef(0);
+  const mutableQueriesResponseRef = useRef<QueryData[] | null | undefined>(
+    cloneDeep(queriesResponse),
+  );
+  const contextMenuRef = createRef<ChartContextMenuRef>();
+
+  // Track previous props for shouldComponentUpdate logic
+  const prevPropsRef = useRef<PrevPropsRef>({
+    queriesResponse,
+    datasource,
+    annotationData,
+    ownState,
+    filterState,
+    height,
+    width,
+    triggerRender,
+    labelsColor,
+    labelsColorMap,
+    formData,
+    cacheBusterProp,
+    emitCrossFilters,
+    postTransformProps,
+  });
+
+  // Handler functions
+  const handleAddFilter = useCallback(
+    (col: string, vals: FilterValue[], merge = true, refresh = true): void => {
+      addFilter?.(col, vals, merge, refresh);
+    },
+    [addFilter],
+  );
+
+  const handleRenderSuccess = useCallback((): void => {
+    if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
+      actions.chartRenderingSucceeded(chartId);
+    }
+
+    // only log chart render time which is triggered by query results change
+    if (hasQueryResponseChangeRef.current) {
+      actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+        slice_id: chartId,
+        viz_type: propVizType,
+        start_offset: renderStartTimeRef.current,
+        ts: new Date().getTime(),
+        duration: Logger.getTimestamp() - renderStartTimeRef.current,
+      });
+    }
+  }, [actions, chartId, chartStatus, propVizType]);
+
+  const handleRenderFailure = useCallback(
+    (error: Error, info: { componentStack: string } | null): void => {
+      logging.warn(error);
+      actions.chartRenderingFailed(
+        error.toString(),
+        chartId,
+        info ? info.componentStack : null,
+      );
+
+      // only trigger render log when query is changed
+      if (hasQueryResponseChangeRef.current) {
+        actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+          slice_id: chartId,
+          has_err: true,
+          error_details: error.toString(),
+          start_offset: renderStartTimeRef.current,
+          ts: new Date().getTime(),
+          duration: Logger.getTimestamp() - renderStartTimeRef.current,
+        });
+      }
+    },
+    [actions, chartId],
+  );
+
+  const handleSetControlValue = useCallback(
+    (name: string, value: unknown): void => {
+      if (setControlValue) {
+        setControlValue(name, value);
+      }
+    },
+    [setControlValue],
+  );
+
+  const handleOnContextMenu = useCallback(
+    (offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
+      contextMenuRef.current?.open(offsetX, offsetY, filters);
+      setState(prev => ({ ...prev, inContextMenu: true }));
+    },
+    [contextMenuRef],
+  );
+
+  const handleContextMenuSelected = useCallback((): void => {
+    setState(prev => ({ ...prev, inContextMenu: false }));
+  }, []);
+
+  const handleContextMenuClosed = useCallback((): void => {
+    setState(prev => ({ ...prev, inContextMenu: false }));
+  }, []);
 
-  shouldComponentUpdate(
-    nextProps: ChartRendererProps,
-    nextState: ChartRendererState,
-  ): boolean {
+  const handleLegendStateChanged = useCallback(
+    (legendState: LegendState): void => {
+      setState(prev => ({ ...prev, legendState }));
+    },
+    [],
+  );
+
+  const handleLegendScroll = useCallback((legendIndex: number): void => {
+    setState(prev => ({ ...prev, legendIndex }));
+  }, []);
+
+  // When viz plugins don't handle `contextmenu` event, fallback handler
+  // calls `handleOnContextMenu` with no `filters` param.
+  const onContextMenuFallback = useCallback(
+    (event: MouseEvent<HTMLDivElement>): void => {
+      if (!state.inContextMenu) {
+        event.preventDefault();
+        handleOnContextMenu(event.clientX, event.clientY);
+      }
+    },
+    [handleOnContextMenu, state.inContextMenu],
+  );
+
+  const setDataMaskCallback = useCallback(
+    (dataMask: DataMask) => {
+      actions?.updateDataMask?.(chartId, dataMask);
+    },
+    [actions, chartId],
+  );
+
+  // Hooks object - memoized
+  const hooks = useMemo<ChartHooks>(
+    () => ({
+      onAddFilter: handleAddFilter,
+      onContextMenu: state.showContextMenu ? handleOnContextMenu : undefined,
+      onError: handleRenderFailure,
+      setControlValue: handleSetControlValue,
+      onFilterMenuOpen,
+      onFilterMenuClose,
+      onLegendStateChanged: handleLegendStateChanged,
+      setDataMask: setDataMaskCallback,
+      onLegendScroll: handleLegendScroll,
+      onChartStateChange,
+    }),
+    [
+      handleAddFilter,
+      handleLegendScroll,
+      handleLegendStateChanged,
+      handleOnContextMenu,
+      handleRenderFailure,
+      handleSetControlValue,
+      onChartStateChange,
+      onFilterMenuClose,
+      onFilterMenuOpen,
+      setDataMaskCallback,
+      state.showContextMenu,
+    ],
+  );
+
+  // shouldComponentUpdate logic - implemented as a useMemo that tracks if we should render
+  // Note: The return value is not used directly, but the useMemo contains necessary
+  // side effects (updating refs).
+  useMemo(() => {
+    const prevProps = prevPropsRef.current;
     const resultsReady =
-      nextProps.queriesResponse &&
-      ['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
-      !nextProps.queriesResponse?.[0]?.error;
+      queriesResponse &&
+      ['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
+      !queriesResponse?.[0]?.error;
 
     if (resultsReady) {
-      if (!isEqual(this.state, nextState)) {
-        return true;
-      }
-      this.hasQueryResponseChange =
-        nextProps.queriesResponse !== this.props.queriesResponse;
+      hasQueryResponseChangeRef.current =
+        queriesResponse !== prevProps.queriesResponse;
 
-      if (this.hasQueryResponseChange) {
-        this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
+      if (hasQueryResponseChangeRef.current) {
+        mutableQueriesResponseRef.current = cloneDeep(queriesResponse);
       }
 
       // Check if any matrixify-related properties have changed
       const hasMatrixifyChanges = (): boolean => {
-        const nextFormData = nextProps.formData as JsonObject;
-        const currentFormData = this.props.formData as JsonObject;
+        const nextFormData = formData as JsonObject;
+        const currentFormData = prevProps.formData as JsonObject;
         const isMatrixifyEnabled =
           nextFormData.matrixify_enable_vertical_layout === true ||
           nextFormData.matrixify_enable_horizontal_layout === true;
@@ -288,272 +442,193 @@ class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
         );
       };
 
-      const nextFormData = nextProps.formData as JsonObject;
-      const currentFormData = this.props.formData as JsonObject;
-
-      return (
-        this.hasQueryResponseChange ||
-        !isEqual(nextProps.datasource, this.props.datasource) ||
-        nextProps.annotationData !== this.props.annotationData ||
-        nextProps.ownState !== this.props.ownState ||
-        nextProps.filterState !== this.props.filterState ||
-        nextProps.height !== this.props.height ||
-        nextProps.width !== this.props.width ||
-        nextProps.triggerRender === true ||
-        nextProps.labelsColor !== this.props.labelsColor ||
-        nextProps.labelsColorMap !== this.props.labelsColorMap ||
+      const nextFormData = formData as JsonObject;
+      const currentFormData = prevProps.formData as JsonObject;
+
+      const shouldRender =
+        hasQueryResponseChangeRef.current ||
+        !isEqual(datasource, prevProps.datasource) ||
+        annotationData !== prevProps.annotationData ||
+        ownState !== prevProps.ownState ||
+        filterState !== prevProps.filterState ||
+        height !== prevProps.height ||
+        width !== prevProps.width ||
+        triggerRender === true ||
+        labelsColor !== prevProps.labelsColor ||
+        labelsColorMap !== prevProps.labelsColorMap ||
         nextFormData.color_scheme !== currentFormData.color_scheme ||
         nextFormData.stack !== currentFormData.stack ||
         nextFormData.subcategories !== currentFormData.subcategories ||
-        nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
-        nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
-        nextProps.postTransformProps !== this.props.postTransformProps ||
-        hasMatrixifyChanges()
-      );
-    }
-    return false;
-  }
-
-  handleAddFilter(
-    col: string,
-    vals: FilterValue[],
-    merge = true,
-    refresh = true,
-  ): void {
-    this.props.addFilter?.(col, vals, merge, refresh);
-  }
-
-  handleRenderSuccess(): void {
-    const { actions, chartStatus, chartId, vizType } = this.props;
-    if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
-      actions.chartRenderingSucceeded(chartId);
-    }
+        cacheBusterProp !== prevProps.cacheBusterProp ||
+        emitCrossFilters !== prevProps.emitCrossFilters ||
+        postTransformProps !== prevProps.postTransformProps ||
+        hasMatrixifyChanges();
+
+      // Update prev props ref
+      prevPropsRef.current = {
+        queriesResponse,
+        datasource,
+        annotationData,
+        ownState,
+        filterState,
+        height,
+        width,
+        triggerRender,
+        labelsColor,
+        labelsColorMap,
+        formData,
+        cacheBusterProp,
+        emitCrossFilters,
+        postTransformProps,
+      };
 
-    // only log chart render time which is triggered by query results change
-    // currently we don't log chart re-render time, like window resize etc
-    if (this.hasQueryResponseChange) {
-      actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
-        slice_id: chartId,
-        viz_type: vizType,
-        start_offset: this.renderStartTime,
-        ts: new Date().getTime(),
-        duration: Logger.getTimestamp() - this.renderStartTime,
-      });
+      return shouldRender;
     }
+    return false;
+  }, [
+    annotationData,
+    cacheBusterProp,
+    chartStatus,
+    datasource,
+    emitCrossFilters,
+    filterState,
+    formData,
+    height,
+    labelsColor,
+    labelsColorMap,
+    ownState,
+    postTransformProps,
+    queriesResponse,
+    triggerRender,
+    width,
+  ]);
+
+  // Skip chart rendering
+  if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
+    return null;
   }
 
-  handleRenderFailure(
-    error: Error,
-    info: { componentStack: string } | null,
-  ): void {
-    const { actions, chartId } = this.props;
-    logging.warn(error);
-    actions.chartRenderingFailed(
-      error.toString(),
-      chartId,
-      info ? info.componentStack : null,
+  renderStartTimeRef.current = Logger.getTimestamp();
+
+  const currentFormData =
+    chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
+  const vizType = currentFormData.viz_type || propVizType;
+
+  // It's bad practice to use unprefixed `vizType` as classnames for chart
+  // container. It may cause css conflicts as in the case of legacy table chart.
+  // When migrating charts, we should gradually add a `superset-chart-` prefix
+  // to each one of them.
+  const snakeCaseVizType = snakeCase(vizType);
+  const chartClassName =
+    vizType === VizType.Table
+      ? `superset-chart-${snakeCaseVizType}`
+      : snakeCaseVizType;
+
+  const webpackHash =
+    process.env.WEBPACK_MODE === 'development'
+      ? `-${
+          typeof __webpack_require__ !== 'undefined' &&
+          typeof __webpack_require__.h === 'function' &&
+          __webpack_require__.h()
+        }`
+      : '';
+
+  let noResultsComponent: ReactNode;
+  const noResultTitle = t('No results were returned for this query');
+  const noResultDescription =
+    source === ChartSource.Explore
+      ? t(
+          'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
+        )
+      : undefined;
+  const noResultImage = 'chart.svg';
+  if (
+    (width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
+    (height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
+  ) {
+    noResultsComponent = (
+      <EmptyState
+        size="large"
+        title={noResultTitle}
+        description={noResultDescription}
+        image={noResultImage}
+      />
+    );
+  } else {
+    noResultsComponent = (
+      <EmptyState title={noResultTitle} image={noResultImage} size="small" />
     );
-
-    // only trigger render log when query is changed
-    if (this.hasQueryResponseChange) {
-      actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
-        slice_id: chartId,
-        has_err: true,
-        error_details: error.toString(),
-        start_offset: this.renderStartTime,
-        ts: new Date().getTime(),
-        duration: Logger.getTimestamp() - this.renderStartTime,
-      });
-    }
-  }
-
-  handleSetControlValue(name: string, value: unknown): void {
-    const { setControlValue } = this.props;
-    if (setControlValue) {
-      setControlValue(name, value);
-    }
-  }
-
-  handleOnContextMenu(
-    offsetX: number,
-    offsetY: number,
-    filters?: ContextMenuFilters,
-  ): void {
-    this.contextMenuRef.current?.open(offsetX, offsetY, filters);
-    this.setState({ inContextMenu: true });
-  }
-
-  handleContextMenuSelected(): void {
-    this.setState({ inContextMenu: false });
-  }
-
-  handleContextMenuClosed(): void {
-    this.setState({ inContextMenu: false });
-  }
-
-  handleLegendStateChanged(legendState: LegendState): void {
-    this.setState({ legendState });
-  }
-
-  // When viz plugins don't handle `contextmenu` event, fallback handler
-  // calls `handleOnContextMenu` with no `filters` param.
-  onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
-    if (!this.state.inContextMenu) {
-      event.preventDefault();
-      this.handleOnContextMenu(event.clientX, event.clientY);
-    }
-  }
-
-  handleLegendScroll(legendIndex: number): void {
-    this.setState({ legendIndex });
   }
 
-  render(): ReactNode {
-    const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
-
-    // Skip chart rendering
-    if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
-      return null;
-    }
-
-    this.renderStartTime = Logger.getTimestamp();
-
-    const {
-      width,
-      height,
-      datasource,
-      annotationData,
-      initialValues,
-      ownState,
-      filterState,
-      chartIsStale,
-      formData,
-      latestQueryFormData,
-      postTransformProps,
-    } = this.props;
-
-    const currentFormData =
-      chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
-    const vizType = currentFormData.viz_type || this.props.vizType;
-
-    // It's bad practice to use unprefixed `vizType` as classnames for chart
-    // container. It may cause css conflicts as in the case of legacy table chart.
-    // When migrating charts, we should gradually add a `superset-chart-` prefix
-    // to each one of them.
-    const snakeCaseVizType = snakeCase(vizType);
-    const chartClassName =
-      vizType === VizType.Table
-        ? `superset-chart-${snakeCaseVizType}`
-        : snakeCaseVizType;
-
-    const webpackHash =
-      process.env.WEBPACK_MODE === 'development'
-        ? `-${
-            // eslint-disable-next-line camelcase
-            typeof __webpack_require__ !== 'undefined' &&
-            // eslint-disable-next-line camelcase, no-undef
-            typeof __webpack_require__.h === 'function' &&
-            // eslint-disable-next-line no-undef, camelcase
-            __webpack_require__.h()
-          }`
-        : '';
-
-    let noResultsComponent: ReactNode;
-    const noResultTitle = t('No results were returned for this query');
-    const noResultDescription =
-      this.props.source === ChartSource.Explore
-        ? t(
-            'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
-          )
-        : undefined;
-    const noResultImage = 'chart.svg';
-    if (
-      (width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
-      (height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
-    ) {
-      noResultsComponent = (
-        <EmptyState
-          size="large"
-          title={noResultTitle}
-          description={noResultDescription}
-          image={noResultImage}
+  // Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
+  // Detail props or if it'll cause side-effects (e.g. excessive re-renders).
+  const drillToDetailProps = getChartMetadataRegistry()
+    .get(vizType)
+    ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
+    ? { inContextMenu: state.inContextMenu }
+    : {};
+  // By pass no result component when server pagination is enabled & the table has:
+  // - a backend search query, OR
+  // - non-empty AG Grid filter model
+  const hasSearchText = (ownState?.searchText?.length || 0) > 0;
+  const hasAgGridFilters =
+    ownState?.agGridFilterModel &&
+    Object.keys(ownState.agGridFilterModel).length > 0;
+
+  const currentFormDataExtended = currentFormData as JsonObject;
+  const bypassNoResult = !(
+    currentFormDataExtended?.server_pagination &&
+    (hasSearchText || hasAgGridFilters)
+  );
+
+  return (
+    <>
+      {state.showContextMenu && (
+        <ChartContextMenu
+          ref={contextMenuRef}
+          id={chartId}
+          formData={currentFormData as QueryFormData}
+          onSelection={handleContextMenuSelected}
+          onClose={handleContextMenuClosed}
         />
-      );
-    } else {
-      noResultsComponent = (
-        <EmptyState title={noResultTitle} image={noResultImage} size="small" />
-      );
-    }
-
-    // Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
-    // Detail props or if it'll cause side-effects (e.g. excessive re-renders).
-    const drillToDetailProps = getChartMetadataRegistry()
-      .get(vizType)
-      ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
-      ? { inContextMenu: this.state.inContextMenu }
-      : {};
-    // By pass no result component when server pagination is enabled & the table has:
-    // - a backend search query, OR
-    // - non-empty AG Grid filter model
-    const hasSearchText = (ownState?.searchText?.length || 0) > 0;
-    const hasAgGridFilters =
-      ownState?.agGridFilterModel &&
-      Object.keys(ownState.agGridFilterModel).length > 0;
-
-    const currentFormDataExtended = currentFormData as JsonObject;
-    const bypassNoResult = !(
-      currentFormDataExtended?.server_pagination &&
-      (hasSearchText || hasAgGridFilters)
-    );
-
-    return (
-      <>
-        {this.state.showContextMenu && (
-          <ChartContextMenu
-            ref={this.contextMenuRef}
-            id={chartId}
-            formData={currentFormData as QueryFormData}
-            onSelection={this.handleContextMenuSelected}
-            onClose={this.handleContextMenuClosed}
-          />
-        )}
-        <div
-          onContextMenu={
-            this.state.showContextMenu ? this.onContextMenuFallback : undefined
-          }
-        >
-          <SuperChart
-            disableErrorBoundary
-            key={`${chartId}${webpackHash}`}
-            id={`chart-id-${chartId}`}
-            className={chartClassName}
-            chartType={vizType}
-            width={width}
-            height={height}
-            annotationData={annotationData}
-            datasource={datasource}
-            initialValues={initialValues}
-            formData={currentFormData}
-            ownState={ownState}
-            filterState={filterState}
-            // eslint-disable-next-line @typescript-eslint/no-explicit-any
-            hooks={this.hooks as any}
-            behaviors={behaviors}
-            queriesData={this.mutableQueriesResponse ?? undefined}
-            onRenderSuccess={this.handleRenderSuccess}
-            onRenderFailure={this.handleRenderFailure}
-            noResults={noResultsComponent}
-            postTransformProps={postTransformProps}
-            emitCrossFilters={emitCrossFilters}
-            legendState={this.state.legendState}
-            enableNoResults={bypassNoResult}
-            legendIndex={this.state.legendIndex}
-            {...drillToDetailProps}
-          />
-        </div>
-      </>
-    );
-  }
+      )}
+      <div
+        onContextMenu={
+          state.showContextMenu ? onContextMenuFallback : undefined
+        }
+      >
+        <SuperChart
+          disableErrorBoundary
+          key={`${chartId}${webpackHash}`}
+          id={`chart-id-${chartId}`}
+          className={chartClassName}
+          chartType={vizType}
+          width={width}
+          height={height}
+          annotationData={annotationData}
+          datasource={datasource}
+          initialValues={initialValues}
+          formData={currentFormData}
+          ownState={ownState}
+          filterState={filterState}
+          hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
+          behaviors={behaviors}
+          queriesData={mutableQueriesResponseRef.current ?? undefined}
+          onRenderSuccess={handleRenderSuccess}
+          onRenderFailure={handleRenderFailure}
+          noResults={noResultsComponent}
+          postTransformProps={postTransformProps}
+          emitCrossFilters={emitCrossFilters}
+          legendState={state.legendState}
+          enableNoResults={bypassNoResult}
+          legendIndex={state.legendIndex}
+          {...drillToDetailProps}
+        />
+      </div>
+    </>
+  );
 }
 
+const ChartRenderer = memo(ChartRendererComponent);
+
 export default ChartRenderer;
diff --git a/superset-frontend/src/components/CopyToClipboard/index.tsx b/superset-frontend/src/components/CopyToClipboard/index.tsx
index 976f0059c30..a7c4b00838d 100644
--- a/superset-frontend/src/components/CopyToClipboard/index.tsx
+++ b/superset-frontend/src/components/CopyToClipboard/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Component, cloneElement, ReactElement } from 'react';
+import { cloneElement, ReactElement, useCallback } from 'react';
 import { t } from '@apache-superset/core';
 import { css, SupersetTheme } from '@apache-superset/core/ui';
 import copyTextToClipboard from 'src/utils/copy';
@@ -24,108 +24,107 @@ import { Tooltip } from '@superset-ui/core/components';
 import withToasts from '../MessageToasts/withToasts';
 import type { CopyToClipboardProps } from './types';
 
-const defaultProps: Partial<CopyToClipboardProps> = {
-  copyNode: <span>{t('Copy')}</span>,
-  onCopyEnd: () => {},
-  shouldShowText: true,
-  wrapped: true,
-  tooltipText: t('Copy to clipboard'),
-  hideTooltip: false,
-};
+function CopyToClip({
+  copyNode = <span>{t('Copy')}</span>,
+  onCopyEnd = () => {},
+  shouldShowText = true,
+  wrapped = true,
+  tooltipText = t('Copy to clipboard'),
+  hideTooltip = false,
+  getText,
+  text,
+  addSuccessToast,
+  addDangerToast,
+}: CopyToClipboardProps) {
+  const copyToClipboard = useCallback(
+    (textToCopy: Promise<string>) => {
+      copyTextToClipboard(() => textToCopy)
+        .then(() => {
+          addSuccessToast(t('Copied to clipboard!'));
+        })
+        .catch(() => {
+          addDangerToast(
+            t(
+              'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
+            ),
+          );
+        })
+        .finally(() => {
+          if (onCopyEnd) onCopyEnd();
+        });
+    },
+    [addSuccessToast, addDangerToast, onCopyEnd],
+  );
 
-class CopyToClip extends Component<CopyToClipboardProps> {
-  static defaultProps = defaultProps;
-
-  constructor(props: CopyToClipboardProps) {
-    super(props);
-    this.copyToClipboard = this.copyToClipboard.bind(this);
-    this.onClick = this.onClick.bind(this);
-  }
-
-  onClick() {
-    if (this.props.getText) {
-      this.props.getText((d: string) => {
-        this.copyToClipboard(Promise.resolve(d));
+  const onClick = useCallback(() => {
+    if (getText) {
+      getText((d: string) => {
+        copyToClipboard(Promise.resolve(d));
       });
     } else {
-      this.copyToClipboard(Promise.resolve(this.props.text || ''));
+      copyToClipboard(Promise.resolve(text || ''));
     }
-  }
-
-  getDecoratedCopyNode() {
-    return cloneElement(this.props.copyNode as ReactElement, {
-      style: { cursor: 'pointer' },
-      onClick: this.onClick,
-    });
-  }
+  }, [getText, text, copyToClipboard]);
 
-  copyToClipboard(textToCopy: Promise<string>) {
-    copyTextToClipboard(() => textToCopy)
-      .then(() => {
-        this.props.addSuccessToast(t('Copied to clipboard!'));
-      })
-      .catch(() => {
-        this.props.addDangerToast(
-          t(
-            'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
-          ),
-        );
-      })
-      .finally(() => {
-        if (this.props.onCopyEnd) this.props.onCopyEnd();
-      });
-  }
+  const getDecoratedCopyNode = useCallback(
+    () =>
+      cloneElement(copyNode as ReactElement, {
+        style: { cursor: 'pointer' },
+        onClick,
+      }),
+    [copyNode, onClick],
+  );
 
-  renderTooltip(cursor: string) {
-    return (
+  const renderTooltip = useCallback(
+    (cursor: string) => (
       <>
-        {!this.props.hideTooltip ? (
+        {!hideTooltip ? (
           <Tooltip
             id="copy-to-clipboard-tooltip"
             placement="topRight"
             style={{ cursor }}
-            title={this.props.tooltipText || ''}
+            title={tooltipText || ''}
             trigger={['hover']}
             arrow={{ pointAtCenter: true }}
           >
-            {this.getDecoratedCopyNode()}
+            {getDecoratedCopyNode()}
           </Tooltip>
         ) : (
-          this.getDecoratedCopyNode()
+          getDecoratedCopyNode()
         )}
       </>
-    );
-  }
+    ),
+    [hideTooltip, tooltipText, getDecoratedCopyNode],
+  );
 
-  renderNotWrapped() {
-    return this.renderTooltip('pointer');
-  }
+  const renderNotWrapped = useCallback(
+    () => renderTooltip('pointer'),
+    [renderTooltip],
+  );
 
-  renderLink() {
-    return (
+  const renderLink = useCallback(
+    () => (
       <span css={{ display: 'inline-flex', alignItems: 'center' }}>
-        {this.props.shouldShowText && this.props.text && (
+        {shouldShowText && text && (
           <span
             data-test="short-url"
             css={(theme: SupersetTheme) => css`
               margin-right: ${theme.sizeUnit}px;
             `}
           >
-            {this.props.text}
+            {text}
           </span>
         )}
-        {this.renderTooltip('pointer')}
+        {renderTooltip('pointer')}
       </span>
-    );
-  }
+    ),
+    [shouldShowText, text, renderTooltip],
+  );
 
-  render() {
-    const { wrapped } = this.props;
-    if (!wrapped) {
-      return this.renderNotWrapped();
-    }
-    return this.renderLink();
+  if (!wrapped) {
+    return renderNotWrapped();
   }
+  return renderLink();
 }
 
 export const CopyToClipboard = withToasts(CopyToClip);
diff --git a/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
index 205e9882e02..bb607479f19 100644
--- a/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
+++ b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
@@ -32,5 +32,5 @@ test('renders a table', () => {
   const tableBody = container.querySelector('.ant-table-tbody');
   expect(tableBody).toBeInTheDocument();
   const rows = tableBody?.getElementsByTagName('tr');
-  expect(rows).toHaveLength(mockDatasource['7__table'].columns.length + 1);
+  expect(rows).toHaveLength(mockDatasource['7__table'].columns.length);
 });
diff --git a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
index 53cda1455b4..7494cc33dda 100644
--- a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
+++ b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent, ReactNode } from 'react';
+import { ReactNode, useState, useCallback, useEffect, useMemo } from 'react';
 import { nanoid } from 'nanoid';
 import { t } from '@apache-superset/core';
 import { styled, css, SupersetTheme } from '@apache-superset/core/ui';
@@ -33,8 +33,8 @@ import Fieldset from '../Fieldset';
 import { recurseReactClone } from '../../utils';
 import {
   type CRUDCollectionProps,
-  type CRUDCollectionState,
   type Sort,
+  SortOrder as SortOrderEnum,
 } from '../../types';
 
 const CrudButtonWrapper = styled.div`
@@ -52,18 +52,18 @@ const StyledButtonWrapper = styled.span`
   `}
 `;
 
-type CollectionItem = { id: string | number; [key: string]: any };
+type CollectionItem = { id: string | number; [key: string]: unknown };
 
 function createKeyedCollection(arr: Array<object>) {
   const collectionArray = arr.map(
-    (o: any) =>
+    (o: Record<string, unknown>) =>
       ({
         ...o,
         id: o.id || nanoid(),
       }) as CollectionItem,
   );
 
-  const collection: Record<PropertyKey, any> = {};
+  const collection: Record<PropertyKey, CollectionItem> = {};
   collectionArray.forEach((o: CollectionItem) => {
     collection[o.id] = o;
   });
@@ -74,252 +74,291 @@ function createKeyedCollection(arr: Array<object>) {
   };
 }
 
-export default class CRUDCollection extends PureComponent<
-  CRUDCollectionProps,
-  CRUDCollectionState
-> {
-  constructor(props: CRUDCollectionProps) {
-    super(props);
-
-    const { collection, collectionArray } = createKeyedCollection(
-      props.collection,
-    );
-    this.state = {
-      expandedColumns: {},
-      collection,
-      collectionArray,
-      sortColumn: '',
-      sort: 0,
-    };
-    this.onAddItem = this.onAddItem.bind(this);
-    this.renderExpandableSection = this.renderExpandableSection.bind(this);
-    this.getLabel = this.getLabel.bind(this);
-    this.onFieldsetChange = this.onFieldsetChange.bind(this);
-    this.changeCollection = this.changeCollection.bind(this);
-    this.handleTableChange = this.handleTableChange.bind(this);
-    this.buildTableColumns = this.buildTableColumns.bind(this);
-    this.toggleExpand = this.toggleExpand.bind(this);
-  }
-
-  componentDidUpdate(prevProps: CRUDCollectionProps) {
-    if (this.props.collection !== prevProps.collection) {
-      const { collection, collectionArray } = createKeyedCollection(
-        this.props.collection,
-      );
+export default function CRUDCollection({
+  allowAddItem = false,
+  allowDeletes = false,
+  collection: propsCollection,
+  columnLabels,
+  columnLabelTooltips,
+  emptyMessage = t('No items'),
+  expandFieldset,
+  itemGenerator,
+  itemCellProps,
+  itemRenderers,
+  onChange,
+  tableColumns,
+  sortColumns = [],
+  stickyHeader = false,
+}: CRUDCollectionProps) {
+  const [expandedColumns, setExpandedColumns] = useState<
+    Record<PropertyKey, boolean>
+  >({});
+  const [collection, setCollection] = useState<
+    Record<PropertyKey, CollectionItem>
+  >(() => createKeyedCollection(propsCollection).collection);
+  const [collectionArray, setCollectionArray] = useState<CollectionItem[]>(
+    () => createKeyedCollection(propsCollection).collectionArray,
+  );
+  const [sortColumn, setSortColumn] = useState<string>('');
+  const [sort, setSort] = useState<SortOrderEnum>(SortOrderEnum.Unsorted);
+
+  // Sync with props.collection changes
+  useEffect(() => {
+    const { collection: newCollection, collectionArray: newCollectionArray } =
+      createKeyedCollection(propsCollection);
+    setCollection(newCollection);
+    setCollectionArray(newCollectionArray);
+  }, [propsCollection]);
+
+  const onCellChange = useCallback(
+    (id: string | number, col: string, val: unknown) => {
+      setCollection(prevCollection => {
+        const updatedCollection = {
+          ...prevCollection,
+          [id]: {
+            ...prevCollection[id],
+            [col]: val,
+          },
+        };
+        return updatedCollection;
+      });
 
-      this.setState(prevState => ({
-        collection,
-        collectionArray,
-        expandedColumns: prevState.expandedColumns,
-      }));
-    }
-  }
-
-  onCellChange(id: string | number, col: string, val: unknown) {
-    this.setState(prevState => {
-      const updatedCollection = {
-        ...prevState.collection,
-        [id]: {
-          ...prevState.collection[id],
-          [col]: val,
-        },
-      };
-      const updatedCollectionArray = prevState.collectionArray.map(item =>
-        item.id === id ? updatedCollection[id] : item,
-      );
+      setCollectionArray(prevCollectionArray => {
+        const updatedCollectionArray = prevCollectionArray.map(item => {
+          if (item.id === id) {
+            return {
+              ...item,
+              [col]: val,
+            };
+          }
+          return item;
+        });
+
+        if (onChange) {
+          onChange(updatedCollectionArray);
+        }
+
+        return updatedCollectionArray;
+      });
+    },
+    [onChange],
+  );
 
-      if (this.props.onChange) {
-        this.props.onChange(updatedCollectionArray);
+  const changeCollection = useCallback(
+    (
+      newCollection: Record<PropertyKey, CollectionItem>,
+      currentCollectionArray: CollectionItem[],
+    ) => {
+      // Preserve existing order instead of recreating from Object.keys()
+      const existingIds = new Set(currentCollectionArray.map(item => item.id));
+      const newCollectionArray: CollectionItem[] = [];
+
+      // First pass: preserve existing order and update items
+      for (const existingItem of currentCollectionArray) {
+        if (newCollection[existingItem.id]) {
+          newCollectionArray.push(newCollection[existingItem.id]);
+        }
       }
-      return {
-        collection: updatedCollection,
-        collectionArray: updatedCollectionArray,
-      };
-    });
-  }
 
-  onAddItem() {
-    if (this.props.itemGenerator) {
-      let newItem = this.props.itemGenerator();
+      // Second pass: add new items
+      for (const item of Object.values(newCollection)) {
+        if (!existingIds.has(item.id)) {
+          newCollectionArray.push(item);
+        }
+      }
+
+      setCollection(newCollection);
+      setCollectionArray(newCollectionArray);
+
+      if (onChange) {
+        onChange(newCollectionArray);
+      }
+    },
+    [onChange],
+  );
+
+  const deleteItem = useCallback(
+    (id: string | number) => {
+      setCollection(prevCollection => {
+        const newColl = { ...prevCollection };
+        delete newColl[id];
+        return newColl;
+      });
+
+      setCollectionArray(prevCollectionArray => {
+        const newCollectionArray = prevCollectionArray.filter(
+          item => item.id !== id,
+        );
+
+        if (onChange) {
+          onChange(newCollectionArray);
+        }
+
+        return newCollectionArray;
+      });
+    },
+    [onChange],
+  );
+
+  const onAddItem = useCallback(() => {
+    if (itemGenerator) {
+      let newItem = itemGenerator() as CollectionItem;
       const shouldStartExpanded = newItem.expanded === true;
       if (!newItem.id) {
         newItem = { ...newItem, id: nanoid() };
       }
       delete newItem.expanded;
 
-      this.setState(
-        prevState => {
-          const newCollection = {
-            ...prevState.collection,
-            [newItem.id]: newItem,
-          };
-          const newExpandedColumns = shouldStartExpanded
-            ? { ...prevState.expandedColumns, [newItem.id]: true }
-            : prevState.expandedColumns;
-          const newCollectionArray = [newItem, ...prevState.collectionArray];
-
-          return {
-            collection: newCollection,
-            collectionArray: newCollectionArray,
-            expandedColumns: newExpandedColumns,
-          };
-        },
-        () => {
-          if (this.props.onChange) {
-            this.props.onChange(this.state.collectionArray);
-          }
-        },
-      );
-    }
-  }
+      setCollection(prevCollection => ({
+        ...prevCollection,
+        [newItem.id]: newItem,
+      }));
 
-  onFieldsetChange(item: any) {
-    this.changeCollection({
-      ...this.state.collection,
-      [item.id]: item,
-    });
-  }
+      setCollectionArray(prevCollectionArray => {
+        const newCollectionArray = [newItem, ...prevCollectionArray];
 
-  getLabel(col: any): string {
-    const { columnLabels } = this.props;
-    let label = columnLabels?.[col] ? columnLabels[col] : col;
-    if (label.startsWith('__')) {
-      label = '';
-    }
-    return label;
-  }
-
-  getTooltip(col: string): string | undefined {
-    const { columnLabelTooltips } = this.props;
-    return columnLabelTooltips?.[col];
-  }
-
-  changeCollection(collection: any) {
-    // Preserve existing order instead of recreating from Object.keys()
-    const existingIds = new Set(
-      this.state.collectionArray.map(item => item.id),
-    );
-    const newCollectionArray: CollectionItem[] = [];
-
-    // First pass: preserve existing order and update items
-    for (const existingItem of this.state.collectionArray) {
-      if (collection[existingItem.id]) {
-        newCollectionArray.push(collection[existingItem.id]);
+        if (onChange) {
+          onChange(newCollectionArray);
+        }
+
+        return newCollectionArray;
+      });
+
+      if (shouldStartExpanded) {
+        setExpandedColumns(prev => ({ ...prev, [newItem.id]: true }));
       }
     }
+  }, [itemGenerator, onChange]);
+
+  const onFieldsetChange = useCallback(
+    (item: CollectionItem) => {
+      changeCollection(
+        {
+          ...collection,
+          [item.id]: item,
+        },
+        collectionArray,
+      );
+    },
+    [changeCollection, collection, collectionArray],
+  );
 
-    // Second pass: add new items
-    for (const item of Object.values(collection) as CollectionItem[]) {
-      if (!existingIds.has(item.id)) {
-        newCollectionArray.push(item);
+  const getLabel = useCallback(
+    (col: string): string => {
+      let label = columnLabels?.[col] ? columnLabels[col] : col;
+      if (label.startsWith('__')) {
+        label = '';
       }
-    }
+      return label;
+    },
+    [columnLabels],
+  );
 
-    this.setState({ collection, collectionArray: newCollectionArray });
+  const getTooltip = useCallback(
+    (col: string): string | undefined => columnLabelTooltips?.[col],
+    [columnLabelTooltips],
+  );
 
-    if (this.props.onChange) {
-      this.props.onChange(newCollectionArray);
-    }
-  }
-
-  deleteItem(id: string | number) {
-    const newColl = { ...this.state.collection };
-    delete newColl[id];
-    this.changeCollection(newColl);
-  }
-
-  toggleExpand(id: any) {
-    this.setState(prevState => ({
-      expandedColumns: {
-        ...prevState.expandedColumns,
-        [id]: !prevState.expandedColumns[id],
-      },
+  const toggleExpand = useCallback((id: string | number) => {
+    setExpandedColumns(prev => ({
+      ...prev,
+      [id]: !prev[id],
     }));
-  }
-
-  handleTableChange(
-    _pagination: TablePaginationConfig,
-    _filters: Record<string, FilterValue | null>,
-    sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
-  ) {
-    const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
-    let newSortColumn = '';
-    let newSortOrder = 0;
-
-    if (columnSorter?.columnKey && columnSorter?.order) {
-      newSortColumn = columnSorter.columnKey as string;
-      newSortOrder = columnSorter.order === 'ascend' ? 1 : 2;
-    }
-
-    const { sortColumns } = this.props;
-    const col = newSortColumn;
+  }, []);
+
+  const handleTableChange = useCallback(
+    (
+      _pagination: TablePaginationConfig,
+      _filters: Record<string, FilterValue | null>,
+      sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
+    ) => {
+      const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
+      let newSortColumn = '';
+      let newSortOrder = SortOrderEnum.Unsorted;
+
+      if (columnSorter?.columnKey && columnSorter?.order) {
+        newSortColumn = columnSorter.columnKey as string;
+        newSortOrder =
+          columnSorter.order === 'ascend'
+            ? SortOrderEnum.Asc
+            : SortOrderEnum.Desc;
+      }
 
-    if (sortColumns?.includes(col) || newSortOrder === 0) {
-      let sortedArray = [...this.props.collection];
+      const col = newSortColumn;
+
+      if (
+        sortColumns?.includes(col) ||
+        newSortOrder === SortOrderEnum.Unsorted
+      ) {
+        let sortedArray = [...propsCollection] as CollectionItem[];
+
+        if (newSortOrder !== SortOrderEnum.Unsorted) {
+          const compareSort = (m: Sort, n: Sort) => {
+            if (typeof m === 'string' && typeof n === 'string') {
+              return (m || '').localeCompare(n || '');
+            }
+            if (typeof m === 'number' && typeof n === 'number') {
+              return m - n;
+            }
+            if (typeof m === 'boolean' && typeof n === 'boolean') {
+              return m === n ? 0 : m ? 1 : -1;
+            }
+            const mStr = String(m ?? '');
+            const nStr = String(n ?? '');
+            return mStr.localeCompare(nStr);
+          };
 
-      if (newSortOrder !== 0) {
-        const compareSort = (m: Sort, n: Sort) => {
-          if (typeof m === 'string' && typeof n === 'string') {
-            return (m || '').localeCompare(n || '');
-          }
-          if (typeof m === 'number' && typeof n === 'number') {
-            return m - n;
+          sortedArray.sort((a: CollectionItem, b: CollectionItem) =>
+            compareSort(a[col] as Sort, b[col] as Sort),
+          );
+          if (newSortOrder === SortOrderEnum.Desc) {
+            sortedArray.reverse();
           }
-          if (typeof m === 'boolean' && typeof n === 'boolean') {
-            return m === n ? 0 : m ? 1 : -1;
-          }
-          const mStr = String(m ?? '');
-          const nStr = String(n ?? '');
-          return mStr.localeCompare(nStr);
-        };
-
-        sortedArray.sort((a: any, b: any) => compareSort(a[col], b[col]));
-        if (newSortOrder === 2) {
-          sortedArray.reverse();
+        } else {
+          const { collectionArray: resetArray } =
+            createKeyedCollection(propsCollection);
+          sortedArray = resetArray;
         }
-      } else {
-        const { collectionArray } = createKeyedCollection(
-          this.props.collection,
-        );
-        sortedArray = collectionArray;
+
+        setCollectionArray(sortedArray);
+        setSortColumn(newSortColumn);
+        setSort(newSortOrder);
       }
+    },
+    [propsCollection, sortColumns],
+  );
 
-      this.setState({
-        collectionArray: sortedArray,
-        sortColumn: newSortColumn,
-        sort: newSortOrder,
-      });
-    }
-  }
-
-  renderExpandableSection(item: any): ReactNode {
-    const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
-    return recurseReactClone(
-      this.props.expandFieldset,
-      Fieldset,
-      propsGenerator,
-    );
-  }
-
-  renderCell(record: any, col: any): ReactNode {
-    const renderer = this.props.itemRenderers?.[col];
-    const val = record[col];
-    const onChange = this.onCellChange.bind(this, record.id, col);
-    return renderer ? renderer(val, onChange, this.getLabel(col), record) : val;
-  }
-
-  buildTableColumns() {
-    const { tableColumns, allowDeletes, sortColumns = [] } = this.props;
-
-    const antdColumns: ColumnsType = tableColumns.map(col => {
-      const label = this.getLabel(col);
-      const tooltip = this.getTooltip(col);
+  const renderExpandableSection = useCallback(
+    (item: CollectionItem): ReactNode => {
+      const propsGenerator = () => ({ item, onChange: onFieldsetChange });
+      return recurseReactClone(expandFieldset, Fieldset, propsGenerator);
+    },
+    [expandFieldset, onFieldsetChange],
+  );
+
+  const renderCell = useCallback(
+    (record: CollectionItem, col: string): ReactNode => {
+      const renderer = itemRenderers?.[col];
+      const val = record[col];
+      const cellOnChange = (newVal: unknown) =>
+        onCellChange(record.id, col, newVal);
+      return renderer
+        ? renderer(val, cellOnChange, getLabel(col), record)
+        : (val as ReactNode);
+    },
+    [itemRenderers, onCellChange, getLabel],
+  );
+
+  const antdColumns = useMemo((): ColumnsType<CollectionItem> => {
+    const columns: ColumnsType<CollectionItem> = tableColumns.map(col => {
+      const label = getLabel(col);
+      const tooltip = getTooltip(col);
       const isSortable = sortColumns.includes(col);
       const currentSortOrder: SortOrder | null | undefined =
-        this.state.sortColumn === col
-          ? this.state.sort === 1
+        sortColumn === col
+          ? sort === SortOrderEnum.Asc
             ? 'ascend'
-            : this.state.sort === 2
+            : sort === SortOrderEnum.Desc
               ? 'descend'
               : null
           : null;
@@ -343,10 +382,10 @@ export default class CRUDCollection extends PureComponent<
             )}
           </>
         ),
-        render: (text: any, record: CollectionItem) =>
-          this.renderCell(record, col),
+        render: (_text: unknown, record: CollectionItem) =>
+          renderCell(record, col),
         onCell: (record: CollectionItem) => {
-          const cellPropsFn = this.props.itemCellProps?.[col];
+          const cellPropsFn = itemCellProps?.[col];
           const val = record[col];
           return cellPropsFn ? cellPropsFn(val, label, record) : {};
         },
@@ -356,7 +395,7 @@ export default class CRUDCollection extends PureComponent<
     });
 
     if (allowDeletes) {
-      antdColumns.push({
+      columns.push({
         key: '__actions',
         dataIndex: '__actions',
         sorter: false,
@@ -380,7 +419,7 @@ export default class CRUDCollection extends PureComponent<
               data-test="crud-delete-icon"
               role="button"
               tabIndex={0}
-              onClick={() => this.deleteItem(record.id)}
+              onClick={() => deleteItem(record.id)}
               iconSize="l"
               iconColor="inherit"
             />
@@ -389,74 +428,81 @@ export default class CRUDCollection extends PureComponent<
       });
     }
 
-    return antdColumns as ColumnsType<CollectionItem>;
-  }
-
-  render() {
-    const {
-      stickyHeader,
-      emptyMessage = t('No items'),
-      expandFieldset,
-    } = this.props;
-
-    const tableColumns = this.buildTableColumns();
-    const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
-      id => this.state.expandedColumns[id],
-    );
-
-    const expandableConfig = expandFieldset
-      ? {
-          expandedRowRender: (record: CollectionItem) =>
-            this.renderExpandableSection(record),
-          rowExpandable: () => true,
-          expandedRowKeys,
-          onExpand: (expanded: boolean, record: CollectionItem) => {
-            this.toggleExpand(record.id);
-          },
-        }
-      : undefined;
-
-    return (
-      <>
-        <CrudButtonWrapper>
-          {this.props.allowAddItem && (
-            <StyledButtonWrapper>
-              <Button
-                buttonSize="small"
-                buttonStyle="secondary"
-                onClick={this.onAddItem}
-                data-test="add-item-button"
-              >
-                <Icons.PlusOutlined
-                  iconSize="m"
-                  data-test="crud-add-table-item"
-                />
-                {t('Add item')}
-              </Button>
-            </StyledButtonWrapper>
-          )}
-        </CrudButtonWrapper>
-        <Table<CollectionItem>
-          data-test="crud-table"
-          columns={tableColumns}
-          data={this.state.collectionArray as CollectionItem[]}
-          rowKey={(record: CollectionItem) => String(record.id)}
-          sticky={stickyHeader}
-          pagination={false}
-          onChange={this.handleTableChange}
-          locale={{ emptyText: emptyMessage }}
-          css={
-            stickyHeader &&
-            css`
-              height: 350px;
-              overflow: auto;
-            `
+    return columns;
+  }, [
+    tableColumns,
+    getLabel,
+    getTooltip,
+    sortColumns,
+    sortColumn,
+    sort,
+    renderCell,
+    itemCellProps,
+    allowDeletes,
+    deleteItem,
+  ]);
+
+  const expandedRowKeys = useMemo(
+    () => Object.keys(expandedColumns).filter(id => expandedColumns[id]),
+    [expandedColumns],
+  );
+
+  const expandableConfig = useMemo(
+    () =>
+      expandFieldset
+        ? {
+            expandedRowRender: (record: CollectionItem) =>
+              renderExpandableSection(record),
+            rowExpandable: () => true,
+            expandedRowKeys,
+            onExpand: (_expanded: boolean, record: CollectionItem) => {
+              toggleExpand(record.id);
+            },
           }
-          expandable={expandableConfig}
-          size={TableSize.Middle}
-          tableLayout="auto"
-        />
-      </>
-    );
-  }
+        : undefined,
+    [expandFieldset, renderExpandableSection, expandedRowKeys, toggleExpand],
+  );
+
+  return (
+    <>
+      <CrudButtonWrapper>
+        {allowAddItem && (
+          <StyledButtonWrapper>
+            <Button
+              buttonSize="small"
+              buttonStyle="secondary"
+              onClick={onAddItem}
+              data-test="add-item-button"
+            >
+              <Icons.PlusOutlined
+                iconSize="m"
+                data-test="crud-add-table-item"
+              />
+              {t('Add item')}
+            </Button>
+          </StyledButtonWrapper>
+        )}
+      </CrudButtonWrapper>
+      <Table<CollectionItem>
+        data-test="crud-table"
+        columns={antdColumns}
+        data={collectionArray}
+        rowKey={(record: CollectionItem) => String(record.id)}
+        sticky={stickyHeader}
+        pagination={false}
+        onChange={handleTableChange}
+        locale={{ emptyText: emptyMessage }}
+        css={
+          stickyHeader &&
+          css`
+            height: 350px;
+            overflow: auto;
+          `
+        }
+        expandable={expandableConfig}
+        size={TableSize.Middle}
+        tableLayout="auto"
+      />
+    </>
+  );
 }
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index fb0c1f597fc..542242584d5 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -17,10 +17,16 @@
  * under the License.
  */
 import rison from 'rison';
-import { PureComponent, useCallback, ReactNode } from 'react';
+import {
+  useCallback,
+  ReactNode,
+  useState,
+  useEffect,
+  useRef,
+  useMemo,
+} from 'react';
 import { connect, ConnectedProps } from 'react-redux';
 import type { JsonObject } from '@superset-ui/core';
-import type { SupersetTheme } from '@apache-superset/core/ui';
 import type { AnyAction } from 'redux';
 import type { ThunkDispatch } from 'redux-thunk';
 import { Radio } from '@superset-ui/core/components/Radio';
@@ -37,7 +43,7 @@ import {
   styled,
   themeObject,
   Alert,
-  withTheme,
+  useTheme,
   t,
 } from '@apache-superset/core/ui';
 import Tabs from '@superset-ui/core/components/Tabs';
@@ -46,7 +52,6 @@ import TableSelector from 'src/components/TableSelector';
 import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
 import TextControl from 'src/explore/components/controls/TextControl';
 import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
-import SpatialControl from 'src/explore/components/controls/SpatialControl';
 import withToasts from 'src/components/MessageToasts/withToasts';
 import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
 import {
@@ -192,7 +197,6 @@ interface DatasourceEditorOwnProps {
   addDangerToast: (msg: string) => void;
   setIsEditing?: (isEditing: boolean) => void;
   currencies?: string[];
-  theme?: SupersetTheme;
 }
 
 interface QueryResultColumn {
@@ -250,21 +254,6 @@ interface ChartUsageData {
   }>;
 }
 
-interface DatasourceEditorState {
-  datasource: DatasourceObject;
-  errors: string[];
-  isSqla: boolean;
-  isEditMode: boolean;
-  databaseColumns: Column[];
-  calculatedColumns: Column[];
-  folders: DatasourceFolder[];
-  metadataLoading: boolean;
-  activeTabKey: string;
-  datasourceType: string;
-  usageCharts: ChartUsageData[];
-  usageChartsCount: number;
-}
-
 interface AbortControllers {
   formatQuery: AbortController | null;
   formatSql: AbortController | null;
@@ -824,189 +813,284 @@ const mapStateToProps = (state: RootState) => ({
 const connector = connect(mapStateToProps, mapDispatchToProps);
 type PropsFromRedux = ConnectedProps<typeof connector>;
 
-type DatasourceEditorProps = DatasourceEditorOwnProps &
-  PropsFromRedux & {
-    theme?: SupersetTheme;
-  };
+type DatasourceEditorProps = DatasourceEditorOwnProps & PropsFromRedux;
+
+function DatasourceEditor({
+  datasource: propsDatasource,
+  onChange = () => {},
+  addSuccessToast,
+  addDangerToast,
+  setIsEditing = () => {},
+  database,
+  runQuery,
+  resetQuery,
+  formatQuery: formatQueryAction,
+}: DatasourceEditorProps) {
+  const theme = useTheme();
+  const isComponentMounted = useRef(false);
+  const abortControllers = useRef<AbortControllers>({
+    formatQuery: null,
+    formatSql: null,
+    syncMetadata: null,
+    fetchUsageData: null,
+  });
+
+  // Initialize datasource state with transformed owners and metrics
+  const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
+    ...propsDatasource,
+    owners: propsDatasource.owners.map(owner => ({
+      value: owner.value || owner.id,
+      label: owner.label || `${owner.first_name} ${owner.last_name}`,
+    })),
+    metrics: propsDatasource.metrics?.map(metric => {
+      const {
+        certified_by: certifiedByMetric,
+        certification_details: certificationDetails,
+      } = metric;
+      const {
+        certification: {
+          details = undefined,
+          certified_by: certifiedBy = undefined,
+        } = {},
+        warning_markdown: warningMarkdown,
+      } = JSON.parse(metric.extra || '{}') || {};
+      return {
+        ...metric,
+        certification_details: certificationDetails || details,
+        warning_markdown: warningMarkdown || '',
+        certified_by: certifiedBy || certifiedByMetric,
+      };
+    }),
+  }));
 
-class DatasourceEditor extends PureComponent<
-  DatasourceEditorProps,
-  DatasourceEditorState
-> {
-  private isComponentMounted: boolean;
+  const [errors, setErrors] = useState<string[]>([]);
+  const [isSqla] = useState(
+    propsDatasource.datasource_type === 'table' ||
+      propsDatasource.type === 'table',
+  );
+  const [isEditMode, setIsEditMode] = useState(false);
+  const [databaseColumns, setDatabaseColumns] = useState<Column[]>(
+    propsDatasource.columns.filter(col => !col.expression),
+  );
+  const [calculatedColumns, setCalculatedColumns] = useState<Column[]>(
+    propsDatasource.columns.filter(col => !!col.expression),
+  );
+  const [folders, setFolders] = useState<DatasourceFolder[]>(
+    propsDatasource.folders || [],
+  );
+  const [metadataLoading, setMetadataLoading] = useState(false);
+  const [activeTabKey, setActiveTabKey] = useState(TABS_KEYS.SOURCE);
+  const [datasourceType, setDatasourceType] = useState(
+    propsDatasource.sql
+      ? DATASOURCE_TYPES.virtual.key
+      : DATASOURCE_TYPES.physical.key,
+  );
+  const [usageCharts, setUsageCharts] = useState<ChartUsageData[]>([]);
+  const [usageChartsCount, setUsageChartsCount] = useState(0);
+
+  const findDuplicates = useCallback(
+    <T,>(arr: T[], accessor: (obj: T) => string): string[] => {
+      const seen: Record<string, null> = {};
+      const dups: string[] = [];
+      arr.forEach((obj: T) => {
+        const item = accessor(obj);
+        if (item in seen) {
+          dups.push(item);
+        } else {
+          seen[item] = null;
+        }
+      });
+      return dups;
+    },
+    [],
+  );
 
-  private abortControllers: AbortControllers;
+  const validate = useCallback(
+    (callback: () => void) => {
+      let validationErrors: string[] = [];
+      let dups: string[];
 
-  static defaultProps = {
-    onChange: () => {},
-    setIsEditing: () => {},
-  };
+      // Looking for duplicate column_name
+      dups = findDuplicates(datasource.columns, obj => obj.column_name);
+      validationErrors = validationErrors.concat(
+        dups.map(name => t('Column name [%s] is duplicated', name)),
+      );
 
-  constructor(props: DatasourceEditorProps) {
-    super(props);
-    this.state = {
... 17179 lines suppressed ...