I'm currently working on a complex React table component which is giving me some performance and usability issues. The primary concern is that, when selecting rows, the scroll position of the table jumps, creating a disorienting experience for the user. I'd love to hear your insights and suggestions on how to optimize this component and fix the stated issue.
Component Overview:
The table component (TableComponent) I'm developing leverages the react-base-table package. Here are some of its functionalities and features:
Dynamic Row Selection: Users can select rows with a simple click or multiple rows using the shift key. Selections are visually highlighted, and the list of selected rows can be manipulated outside of this table. They are also manipulated outside of this table using the SelectedRowsSummary component to save or edit sequences.
Filtering: A filtering dialog allows users to apply filters to specific columns. Active filters italicize column headers.
Column Reordering: A separate dialog lets users reorder columns.
Sorting: Columns are sortable, and the component keeps track of the current sort states . State Restoration: The component can restore its state, including applied filters and sort order, from local storage.
Technical Challenges:
Scroll Position Jump: When rows are selected, the scroll position of the table shifts unexpectedly. I've attempted to store the scroll position in a ref and then restore it, but this isn't a smooth solution.
Mounting & Unmounting: Due to the dynamic nature of the table, and the way React works, components mount and unmount frequently. This behavior results in the ref to the table sometimes being undefined when the layout effect runs. As a workaround, I'm querying the DOM directly to get the scrollable element, but I'm aware this isn't the React way of doing things.
Efficiency: The current structure and logic, especially with all the memoization and state management, may not be the most efficient. I'm concerned this might be contributing to the performance issues.
Seeking Suggestions:
I'm particularly interested in insights on:
How to maintain the scroll position smoothly after row selection.
How to efficiently manage state, especially with such a dynamic component.
Whether there's a more 'React-like' way to handle the challenges I'm facing, rather than querying the DOM directly.
Any other general optimizations or best practices that can be applied to this component.
All the code from the relevant components:
Table
import React, { useState, useEffect, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
import Table, { AutoResizer, Column, ColumnShape } from 'react-base-table';
import { Notification } from '@sms/plasma-ui';
import moment from 'moment';
import styled from 'styled-components';
import { pssApi } from '../../services';
import { ReorderColumnsDialog, SelectedRowsSummary, FilterDialog } from './components';
import { isSameSlab, extractAllSlabs, getData, handleData } from './utils';
import 'react-base-table/styles.css';
import html2canvas from 'html2canvas';
const TableComponent = ({
columns,
data,
multiValueFilter,
route,
}: {
columns: any[];
data: any[];
multiValueFilter: any;
route: any;
}) => {
const [isDialogOpen, setDialogOpen] = useState(false);
const [selectedColumn, setSelectedColumn] = useState(null);
const [currentFilterValue, setCurrentFilterValue] = useState(undefined);
const [selectedRowIds, setSelectedRowIds] = useState([]);
const [selectedRowIdsOld, setSelectedRowIdsOld] = useState([]);
const [firstRowIndex, setFirstRowIndex] = useState(null);
const [reorderDialogOpen, setReorderDialogOpen] = useState(false);
const [appliedFilters, setAppliedFilters] = useState>({});
const [sortConfig, setSortConfig] = useState(null);
const [dialogPosition, setDialogPosition] = useState({ x: 0, y: 0 });
const [dialogsState, setDialogsState] = useState>({});
const [showSelectedOnTop, setShowSelectedOnTop] = useState(false);
const [sequenceName, setSequenceName] = useState('');
const [sequenceId, setSequenceId] = useState(null);
const [startDate, setStartDate] = useState(undefined);
const [casterType, setCasterType] = useState(null);
const [oneCasterChecked, setOneCasterChecked] = useState(false);
const [currentColumns, setCurrentColumns] = useState(() => getData(columns));
const [isEdition, setIsEdition] = useState(false);
const [dataScenario, setDataScenario] = useState(null);
const scrollPositionRef = useRef(0);
const sortStatesConstructorFunction = () => {
return currentColumns.reduce((acc: any, column: any) => {
if (column?.defaultSort) {
acc[column.accessor] = column.defaultSort;
}
return acc;
}, {});
};
const [sortStates, setSortStates] = useState>(sortStatesConstructorFunction);
const singleSort = (data: any[], dataKey: string, order: 'asc' | 'desc') => {
return [...data].sort((a, b) => {
const dir = order === 'asc' ? 1 : -1;
if (a[dataKey] > b[dataKey]) return dir;
if (a[dataKey] {
setShowSelectedOnTop((prev) => !prev);
}, []);
const handleClearAllFilters = useCallback(() => {
setAppliedFilters({});
setSortConfig(null);
setSortStates(sortStatesConstructorFunction);
if (isEdition && sequenceId) {
localStorage.removeItem(`sequenceTableState_${sequenceId}`);
}
}, [setAppliedFilters, setSortConfig, isEdition, sequenceId]);
const filteredAndSortedData = useMemo(() => {
let outputData = multiValueFilter(data, appliedFilters);
if (sortConfig) {
const { dataKey, order } = sortConfig;
outputData = singleSort(outputData, dataKey, order);
}
if (showSelectedOnTop) {
const selectedItems = [];
const unselectedItems = [...outputData];
for (const rowId of selectedRowIds) {
const index = unselectedItems.findIndex((item) => item.id === rowId);
if (index !== -1) {
selectedItems.push(unselectedItems[index]);
unselectedItems.splice(index, 1);
}
}
return [...selectedItems, ...unselectedItems];
}
return outputData;
}, [appliedFilters, data, multiValueFilter, sortConfig, selectedRowIds, showSelectedOnTop]);
const setFilter = useCallback(
(
columnAccessor: string,
value:
| string
| string[]
| { datetime: { startDate: moment.Moment | null; endDate: moment.Moment | null } }
| undefined,
) => {
setAppliedFilters((prev) => ({
...prev,
[columnAccessor]: value,
}));
},
[setAppliedFilters],
);
const onColumnSort = useCallback(
({ column, key, order }: any) => {
let dataKey = column.dataKey ?? column.accessor;
setSortStates((prev) => {
if (prev[key] === 'desc') {
const newState = { ...prev };
delete newState[key];
return newState;
}
return { ...prev, [key]: order };
});
setSortConfig({ key, order, dataKey });
},
[setSortStates, setSortConfig],
);
const updateDialogState = useCallback(
(accessor: string, newValues: any) => {
setDialogsState((prev) => ({
...prev,
[accessor]: {
...prev[accessor],
...newValues,
},
}));
},
[setDialogsState],
);
const toggleRowSelection = useCallback(
async (rowId: any, index: number, shiftKey: boolean) => {
const scrollableElement = document.querySelector('[class*=BaseTable__body]');
if (scrollableElement) {
scrollPositionRef.current = scrollableElement.scrollTop;
}
setSelectedRowIds((prevIds: any) => {
const newSelectedRowIds = [...prevIds];
if (shiftKey && firstRowIndex !== null) {
const start = Math.min(firstRowIndex, index);
const end = Math.max(firstRowIndex, index);
const idsToSelect = filteredAndSortedData.slice(start, end + 1).map((row: any) => row.id);
newSelectedRowIds.push(...idsToSelect);
} else if (newSelectedRowIds.includes(rowId)) {
const index = newSelectedRowIds.indexOf(rowId);
if (index > -1) {
newSelectedRowIds.splice(index, 1);
}
} else {
setFirstRowIndex(index);
newSelectedRowIds.push(rowId);
}
return Array.from(new Set(newSelectedRowIds));
});
},
[setSelectedRowIds, firstRowIndex, filteredAndSortedData],
);
const selectedRows = useMemo(() => {
return selectedRowIds.map((rowId) => data.find((row) => row.id === rowId)).filter(Boolean);
}, [data, selectedRowIds]);
const currentDialogState = useMemo(() => dialogsState[selectedColumn?.accessor], [
dialogsState,
selectedColumn?.accessor,
]);
useEffect(() => {
const handleEditSequence = async (id: string) => {
try {
const { data: sequence } = await pssApi.getSequenceById({
params: {
id,
},
});
if (!sequence) {
return;
}
setSequenceName(sequence?.name);
setStartDate(moment(sequence?.startDate));
if (sequence?.scheduledCaster) {
setCasterType(sequence?.scheduledCaster);
setOneCasterChecked(true);
} else {
setOneCasterChecked(false);
}
const { data: dataScenario } = await pssApi.getScenarioMaterialList({ params: { sequenceScenarioId: id } });
const allSlabs = extractAllSlabs(dataScenario);
setDataScenario(dataScenario);
const newSelectedRowIds: string[] = [];
for (let i = 0; i ;
if (id) {
handleEditSequence(id);
} else {
setSequenceName('');
setSelectedRowIds([]);
setSelectedRowIdsOld([]);
setIsEdition(false);
setSequenceId(null);
setStartDate(undefined);
setDataScenario(null);
setOneCasterChecked(false);
setCasterType(null);
}
}, [route]);
const RowRenderer = useMemo(
() => ({
rowIndex,
rowData,
cells,
toggleRowSelection,
selectedRowIds,
}: {
cells: React.ReactNode[];
columns: ColumnShape;
rowData: any;
rowIndex: number;
toggleRowSelection: (rowId: string, index: number, shiftKey: boolean) => void;
selectedRowIds: string[];
}) => {
const isSelected = selectedRowIds.includes(rowData.id);
const rowStyle = {
backgroundColor: isSelected ? '#cce6ff' : '#fff',
fontWeight: isSelected ? 'bold' : 'normal',
display: 'flex',
alignItems: 'stretch',
};
const wrappedCells = cells.map((cell: any, index: number) => {cell});
return (
toggleRowSelection(rowData.id, rowIndex, event.shiftKey)}>
{wrappedCells}
);
},
[],
);
const CellRenderer = useMemo(
() => ({ cellData }: { cellData: string }) => {
const cellStyle = {
display: 'flex',
alignItems: 'center',
padding: '8px',
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'background-color 0.2s',
};
return {cellData};
},
[],
);
const headerCellStyle = {
fontWeight: 'bold',
cursor: 'pointer',
padding: '0 4px',
alignItems: 'center',
whiteSpace: 'nowrap',
};
const Container = styled.div`
width: 100%;
height: calc(100vh - 240px);
`;
const handleColumnResizeEnd = useCallback(
({ column, width }: { column: ColumnShape; width: number }) => {
const newColumns = [...currentColumns];
const columnIndex = newColumns.findIndex((col) => col.accessor === column.dataKey);
if (columnIndex !== -1) {
newColumns[columnIndex].defaultWidth = width;
handleData(newColumns);
}
},
[currentColumns],
);
const openFilterDialog = useCallback(
(event: React.MouseEvent, column: ColumnShape) => {
event.stopPropagation();
setSelectedColumn(column);
if (Object.keys(appliedFilters).length) {
const currentFilter = appliedFilters[column.accessor];
setCurrentFilterValue(currentFilter ? currentFilter : undefined);
}
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.right + 10;
const y = rect.bottom + 10;
const dialogWidth = 300;
const dialogHeight = 200;
const maxX = window.innerWidth - dialogWidth - 10;
const maxY = window.innerHeight - dialogHeight - 10;
const dialogX = Math.min(x, maxX);
const dialogY = Math.min(y, maxY);
setDialogPosition({ x: dialogX, y: dialogY });
setDialogOpen(true);
},
[appliedFilters],
);
useLayoutEffect(() => {
const restoreScroll = () => {
const scrollableElement = document.querySelector('[class*=BaseTable__body]');
if (scrollableElement) {
scrollableElement.scrollTop = scrollPositionRef.current;
}
};
const timeoutId = setTimeout(restoreScroll, 1);
return () => {
clearTimeout(timeoutId);
};
}, [selectedRowIds]);
return (
{({ width, height }) => (
(
)}
>
{currentColumns
.filter((column: any) => !column?.hidden)
.map((column: any, index: number) => (
(
openFilterDialog(event, column)}
>
{column.Header}
)}
cellRenderer={(props: {
cellData: any;
columns: ColumnShape[];
column: ColumnShape;
columnIndex: number;
rowData: unknown;
rowIndex: number;
container: Table;
}) => }
/>
))}
)}
{isDialogOpen && (
setDialogOpen(false)}
setFilter={setFilter}
position={dialogPosition}
setCurrentFilterValue={setCurrentFilterValue}
data={data}
dialogState={currentDialogState || {}}
updateDialogState={updateDialogState}
onColumnSort={onColumnSort}
/>
)}
{reorderDialogOpen && (
setReorderDialogOpen(false)}
onUpdateColumns={setCurrentColumns}
/>
)}
);
};
export default React.memo(TableComponent);
Thank you for your time and expertise!