Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 23 additions & 70 deletions src/DataGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react';
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { Key, KeyboardEvent } from 'react';
import { flushSync } from 'react-dom';

Expand All @@ -10,17 +10,22 @@ import {
useColumnWidths,
useGridDimensions,
useLatestFunc,
useScrollState,
useScrollToPosition,
useShouldFocusPosition,
useViewportColumns,
useViewportRows,
type HeaderRowSelectionContextValue
type HeaderRowSelectionContextValue,
type PartialPosition
} from './hooks';
import {
abs,
assertIsValidKeyGetter,
canExitGrid,
classnames,
createCellEvent,
focusCell,
getCellStyle,
getCellToScroll,
getColSpan,
getLeftRightKey,
getNextSelectedCellPosition,
Expand Down Expand Up @@ -65,8 +70,6 @@ import EditCell from './EditCell';
import GroupedColumnHeaderRow from './GroupedColumnHeaderRow';
import HeaderRow from './HeaderRow';
import { defaultRenderRow } from './Row';
import type { PartialPosition } from './ScrollToCell';
import ScrollToCell from './ScrollToCell';
import { default as defaultRenderSortStatus } from './sortStatus';
import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell';
import { rootClassname, viewportDraggingClassname } from './style/core';
Expand Down Expand Up @@ -110,6 +113,7 @@ type SharedDivProps = Pick<
| 'aria-rowcount'
| 'className'
| 'style'
| 'onScroll'
>;

export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends SharedDivProps {
Expand Down Expand Up @@ -195,8 +199,6 @@ export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends Sha
>;
/** Function called whenever cell selection is changed */
onSelectedCellChange?: Maybe<(args: CellSelectArgs<NoInfer<R>, NoInfer<SR>>) => void>;
/** Callback triggered when the grid is scrolled */
onScroll?: Maybe<(event: React.UIEvent<HTMLDivElement>) => void>;
/** Callback triggered when column is resized */
onColumnResize?: Maybe<(column: CalculatedColumn<R, SR>, width: number) => void>;
/** Callback triggered when columns are reordered */
Expand Down Expand Up @@ -307,19 +309,22 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const enableVirtualization = rawEnableVirtualization ?? true;
const direction = rawDirection ?? 'ltr';

/**
* ref
*/
const gridRef = useRef<HTMLDivElement>(null);

/**
* states
*/
const [scrollTop, setScrollTop] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const { scrollTop, scrollLeft } = useScrollState(gridRef);
const [gridWidth, gridHeight] = useGridDimensions({ gridRef });
const [columnWidthsInternal, setColumnWidthsInternal] = useState(
(): ColumnWidths => columnWidthsRaw ?? new Map()
);
const [isColumnResizing, setIsColumnResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [draggedOverRowIdx, setDraggedOverRowIdx] = useState<number | undefined>(undefined);
const [scrollToPosition, setScrollToPosition] = useState<PartialPosition | null>(null);
const [shouldFocusCell, setShouldFocusCell] = useState(false);
const [previousRowIdx, setPreviousRowIdx] = useState(-1);

const isColumnWidthsControlled =
Expand All @@ -340,7 +345,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
[columnWidths]
);

const [gridRef, gridWidth, gridHeight] = useGridDimensions();
const {
columns,
colSpanColumns,
Expand Down Expand Up @@ -372,6 +376,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const [selectedPosition, setSelectedPosition] = useState(
(): SelectCellState | EditCellState<R> => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' })
);
const { setScrollToPosition, scrollToElement } = useScrollToPosition({ gridRef });
const { shouldFocusPositionRef } = useShouldFocusPosition({ gridRef, selectedPosition });

/**
* computed values
Expand Down Expand Up @@ -480,19 +486,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const selectHeaderCellLatest = useLatestFunc(selectHeaderCell);

/**
* effects
* Misc hooks
*/
useLayoutEffect(() => {
if (shouldFocusCell) {
if (selectedPosition.idx === -1) {
focusRow(gridRef.current!);
} else {
focusCell(gridRef.current!);
}
setShouldFocusCell(false);
}
}, [shouldFocusCell, selectedPosition.idx, gridRef]);

useImperativeHandle(
ref,
(): DataGridHandle => ({
Expand Down Expand Up @@ -621,16 +616,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
}
}

function handleScroll(event: React.UIEvent<HTMLDivElement>) {
const { scrollTop, scrollLeft } = event.currentTarget;
flushSync(() => {
setScrollTop(scrollTop);
// scrollLeft is nagative when direction is rtl
setScrollLeft(abs(scrollLeft));
});
onScroll?.(event);
}

function updateRow(column: CalculatedColumn<R, SR>, rowIdx: number, row: R) {
if (typeof onRowsChange !== 'function') return;
if (row === rows[rowIdx]) return;
Expand Down Expand Up @@ -814,7 +799,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
// Avoid re-renders if the selected cell state is the same
scrollIntoView(getCellToScroll(gridRef.current!));
} else {
setShouldFocusCell(options?.shouldFocusCell === true);
shouldFocusPositionRef.current = options?.shouldFocusCell === true;
setSelectedPosition({ ...position, mode: 'SELECT' });
}

Expand Down Expand Up @@ -1004,7 +989,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const closeOnExternalRowChange = column.editorOptions?.closeOnExternalRowChange ?? true;

const closeEditor = (shouldFocusCell: boolean) => {
setShouldFocusCell(shouldFocusCell);
shouldFocusPositionRef.current = shouldFocusCell;
setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'SELECT' }));
};

Expand Down Expand Up @@ -1191,7 +1176,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
}}
dir={direction}
ref={gridRef}
onScroll={handleScroll}
onScroll={onScroll}
onKeyDown={handleKeyDown}
onCopy={handleCellCopy}
onPaste={handleCellPaste}
Expand Down Expand Up @@ -1303,43 +1288,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
{/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */}
{renderMeasuringCells(viewportColumns)}

{scrollToPosition !== null && (
<ScrollToCell
scrollToPosition={scrollToPosition}
setScrollToCellPosition={setScrollToPosition}
gridRef={gridRef}
/>
)}
{scrollToElement}
</div>
);
}

function getRowToScroll(gridEl: HTMLDivElement) {
return gridEl.querySelector<HTMLDivElement>(':scope > [role="row"][tabindex="0"]');
}

function getCellToScroll(gridEl: HTMLDivElement) {
return gridEl.querySelector<HTMLDivElement>(':scope > [role="row"] > [tabindex="0"]');
}

function isSamePosition(p1: Position, p2: Position) {
return p1.idx === p2.idx && p1.rowIdx === p2.rowIdx;
}

function focusElement(element: HTMLDivElement | null, shouldScroll: boolean) {
if (element === null) return;

if (shouldScroll) {
scrollIntoView(element);
}

element.focus({ preventScroll: true });
}

function focusRow(gridEl: HTMLDivElement) {
focusElement(getRowToScroll(gridEl), true);
}

function focusCell(gridEl: HTMLDivElement, shouldScroll = true) {
focusElement(getCellToScroll(gridEl), shouldScroll);
}
42 changes: 0 additions & 42 deletions src/ScrollToCell.tsx

This file was deleted.

3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ export * from './useGridDimensions';
export * from './useLatestFunc';
export * from './useRovingTabIndex';
export * from './useRowSelection';
export * from './useScrollState';
export * from './useScrollToPosition';
export * from './useShouldFocusPosition';
export * from './useViewportColumns';
export * from './useViewportRows';
13 changes: 8 additions & 5 deletions src/hooks/useGridDimensions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLayoutEffect, useState } from 'react';
import { flushSync } from 'react-dom';

export function useGridDimensions() {
const gridRef = useRef<HTMLDivElement>(null);
export function useGridDimensions({
gridRef
}: {
gridRef: React.RefObject<HTMLDivElement | null>;
}) {
const [inlineSize, setInlineSize] = useState(1);
const [blockSize, setBlockSize] = useState(1);

Expand Down Expand Up @@ -32,7 +35,7 @@ export function useGridDimensions() {
return () => {
resizeObserver.disconnect();
};
}, []);
}, [gridRef]);

return [gridRef, inlineSize, blockSize] as const;
return [inlineSize, blockSize] as const;
}
66 changes: 66 additions & 0 deletions src/hooks/useScrollState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useCallback, useSyncExternalStore } from 'react';

import { abs } from '../utils';

interface ScrollState {
readonly scrollTop: number;
readonly scrollLeft: number;
}

const initialScrollState: ScrollState = {
scrollTop: 0,
scrollLeft: 0
};

function getServerSnapshot() {
return initialScrollState;
}

const scrollStateMap = new WeakMap<React.RefObject<HTMLDivElement | null>, ScrollState>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a benefit of using a map instead of setting a local state inside the component?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you suggesting we keep using useState instead of useSyncExternalStore?

Copy link
Copy Markdown
Collaborator Author

@nstepien nstepien Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function Test() {
  const [resolvers, setResolvers] = useState<PromiseWithResolvers<void>>(() =>
    Promise.withResolvers()
  );

  return (
    <>
      <button type="button" onClick={() => setResolvers(Promise.withResolvers())}>
        New promise
      </button>
      <button type="button" onClick={() => resolvers.resolve()}>
        Resolve
      </button>

      <Suspense fallback="Loading...">
        <Inner promise={resolvers.promise} />
      </Suspense>
    </>
  );
}


function Inner({ promise }: { promise: Promise<void> }) {
  useState(() => {
    console.log('init state');
  });

  use(promise);

  return 'ok';
}

In this example the useState init function in <Inner> is called twice, then once more after the promise resolves. (multiply by two in StrictMode)
Once React commits a render tree to the DOM though, then the init function is not called again.

This also happens when the suspense is triggered deeper in the tree:

function Inner({ promise }: { promise: Promise<void> }) {
  useState(() => {
    console.log('init state');
  });

  // use(promise);

  return (
    <>
      <InnerSide />
      <InnerDeep promise={promise} />
    </>
  );
}

function subscribe() {
  console.log('subscribe');

  return () => {
    console.log('unsubscribe');
  };
}

function getSnapshot() {
  console.log('get snapshot');
}

function InnerSide() {
  useState(() => {
    console.log('init state side');
  });

  useSyncExternalStore(subscribe, getSnapshot);

  return 'side';
}

function InnerDeep({ promise }: { promise: Promise<void> }) {
  useState(() => {
    console.log('init state deep');
  });

  use(promise);

  return 'ok';
}

React only stops re-initializing states when it renders the tree under <Suspense> to the DOM (as I understand it).
So <DataGrid> could be affected if anything suspends the render tree it is in.
Also, subscribe is called just after useLayoutEffects, but it is not cleaned up if the component suspends again.
useTransition and useDeferredValue also trigger concurrent rendering which may affect all this.

All that to say... I guess it's slightly better to have a single WeakMap?


export function useScrollState(gridRef: React.RefObject<HTMLDivElement | null>): ScrollState {
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (gridRef.current === null) return () => {};

const el = gridRef.current;

// prime the scroll state map with the initial values
setScrollState();

function setScrollState() {
const { scrollTop } = el;
// scrollLeft is negative when direction is rtl
const scrollLeft = abs(el.scrollLeft);

const prev = scrollStateMap.get(gridRef) ?? initialScrollState;
if (prev.scrollTop === scrollTop && prev.scrollLeft === scrollLeft) {
return false;
}

scrollStateMap.set(gridRef, { scrollTop, scrollLeft });
return true;
}

function onScroll() {
if (setScrollState()) {
onStoreChange();
}
}

el.addEventListener('scroll', onScroll);

return () => el.removeEventListener('scroll', onScroll);
},
[gridRef]
);

const getSnapshot = useCallback((): ScrollState => {
// gridRef.current is null during initial render, suspending, or <Activity mode="hidden">
// to avoid returning a different state in those cases,
// we key the ref object instead of the element itself
return scrollStateMap.get(gridRef) ?? initialScrollState;
}, [gridRef]);

return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll state now uses useSyncExternalStore instead of 2xuseState+flushSync

}
Loading