The Performance Issue in MUI DataGrid Checkboxes

Tracing a performance issue in MUI X DataGrid Pro and finding out why toggling one checkbox re-renders every row on screen.


Ariesta Putra
Ariesta Putra
8 September 2026
6 min read

Screen recording of a checkbox in a booking table feeling delayed when clicked
The delay on row selection (left) versus the fix (right)

Identifying the Problem

We were building a reservation app with Next.js and MUI on the frontend. At first everything looked fine, but once I joined and started migrating hundreds of thousands of booking records, the team ran into a performance issue on a table using @mui/x-data-grid-pro version ^7.29.9.

That table listed bookings. The problem showed up when users selected rows with a checkbox: every time a checkbox was clicked, there was a visible delay before the change showed up, as in the Figure above.

At the time, the source code was still managed by a vendor and the project was still in development, so we could only file a ticket about it.

Unfortunately, the answer we got was that the issue was indeed related to MUI DataGrid, with no fix offered beyond a recommendation to reduce the number of rows shown per page. With nothing further to go on, the ticket was closed.

A bit disappointing, but understandable. He was a solo hard carry on this project, so rather than let the issue stall development, we agreed to close the ticket.

The workaround was to cap how many rows show per page, recommended at around 20 rows per page max.

We accepted that at the time since the project was still moving and there wasn't much room to dig deeper.

A few months later, after the project shipped and the source code moved to in-house maintenance, I reopened that old issue.

I started looking for other developers hitting the same problem. In the MUI X repository, I found a few issues with very similar symptoms:

Unfortunately, none of them had a fix that actually applied to our case.

I started by profiling with React Profiler to see what actually happens when a single checkbox is clicked. That's where it got interesting: every time one checkbox was clicked, MuiDataGridVirtualScrollerRenderZone re-rendered, and every GridRow currently visible on screen re-rendered along with it, even though only one row's selection state had changed.

That's clearly not ideal. If only one row changed, only that row should need to re-render. This is where I went further into the @mui/x-data-grid source to figure out why this was happening.

React DevTools render highlight showing every GridRow lighting up on a single checkbox toggle
React Profiler flamegraph showing MuiDataGridVirtualScrollerRenderZone and every GridRow re-rendering on a single checkbox toggle
[1/2]
Render highlight per checkbox toggle: not just one row, every visible GridRow lights up too.

Finding the Root Cause

After digging through the MUI DataGrid source, I found that GridRow receives a selected: boolean prop. Then I found the interesting part, inside useGridVirtualScroller:

// hooks/features/virtualization/useGridVirtualScroller.js
const selectedRowsLookup = useGridSelector(apiRef, selectedIdsLookupSelector);

That's where the problem starts to show: useGridVirtualScroller subscribes to the entire selection lookup object via selectedIdsLookupSelector.

When one row is selected or unselected, the contents of the selection lookup change. The selector then produces a new object because the selection state genuinely changed. As a result, useGridVirtualScroller detects that change and re-renders.

The problem is that useGridVirtualScroller is responsible for rendering the rows visible in the virtualized render zone. When this hook runs again, the JSX for those rows gets rebuilt too.

Roughly, the flow looks like this:

Click checkbox
      ↓
rowSelection changes
      ↓
selectedIdsLookup changes
      ↓
useGridVirtualScroller triggers
      ↓
Render zone re-renders
      ↓
Every visible GridRow gets reprocessed

Even though the actual change only happened on one row.

Confirming This Is Actually a MUI Version Issue

I realized I wasn't the only one hitting this. Several issues in the MUI X repo showed similar symptoms.

I then found MUI X's documentation on DataGrid Performance, which has a section worth noting: Visualizing the re-rendering process.

I tried that demo to see how DataGrid re-renders, but it didn't reproduce the same issue I was seeing. So I forked the demo's source code and swapped in the same DataGrid version our project used.

Then I compared the rendering results.

Comparison of render highlights between an old and new MUI DataGrid version on checkbox toggle
Render highlight comparison: newer version (left) only re-renders the changed row, older version (right) re-renders every visible row.

My suspicion turned out to be correct. With the DataGrid version we were using, toggling one checkbox caused other visible rows to re-render as well, while on the newer version, the behavior was much better.

From that experiment, I was fairly confident the issue we were seeing was a problem with the specific MUI DataGrid version we were on, not simply a matter of large data volume or the user's device specs.

The Workaround

The next question was: should we upgrade MUI?

In theory, upgrading is the most sensible fix. But upgrading MUI X on a project that's already fairly large in scope takes real effort and testing, and we'd need to make sure there were no behavior changes or breaking changes to a DataGrid component used across many parts of the app.

So I looked for a simpler workaround instead, starting from a more basic question: what is this checkbox actually used for? It's only used to track which Ticket IDs are currently selected, to be processed in a follow-up action, such as:

  • printing a boarding pass;
  • running a bulk update;
  • or running an action against several bookings at once.

There was no other requirement forcing that selection to live in DataGrid's internal state. So there was no real reason a value this simple needed to keep going through apiRef.current.state.rowSelection, which in turn caused selectedIdsLookupSelector to change and made useGridVirtualScroller react to every checkbox change.

Rather than keep fighting DataGrid's built-in subscription, we decided to separate selection state from DataGrid entirely. Selection state moved to a small external store. This store doesn't use React state and doesn't depend on apiRef. To keep subscriptions granular, we used useSyncExternalStore, so each row only subscribes to its own selection state.

Here's the implementation:

export function createRowSelectionStore(): RowSelectionStore {
  const selected = new Set<string>();
  const rowListeners = new Map<string, Set<() => void>>();
  const allListeners = new Set<() => void>();
  let version = 0;

  const notifyRow = (id: string) => {
    rowListeners.get(id)?.forEach((listener) => listener());
  };

  const notifyAll = () => {
    version += 1;
    allListeners.forEach((listener) => listener());
  };

  return {
    isSelected: (id) => selected.has(id),

    setSelected: (id, value) => {
      const had = selected.has(id);

      if (value === had) return;

      value ? selected.add(id) : selected.delete(id);

      notifyRow(id);
      notifyAll();
    },

    // ...setMany, clear, getSelectedIds

    subscribeRow: (id, listener) => {
      let set = rowListeners.get(id);

      if (!set) {
        rowListeners.set(id, (set = new Set()));
      }

      set.add(listener);

      return () => set!.delete(listener);
    },

    subscribeAll: (listener) => {
      allListeners.add(listener);

      return () => allListeners.delete(listener);
    },

    getVersion: () => version,
  };
}

Then the checkbox on each row reads selection state through a dedicated hook:

function useRowSelected(store: RowSelectionStore, id: string) {
  return useSyncExternalStore(
    useCallback(
      (listener) => store.subscribeRow(id, listener),
      [store, id]
    ),
    () => store.isSelected(id),
    () => false, // getServerSnapshot - required for SSR
  );
}

const RowCheckboxCell = React.memo(function RowCheckboxCell({
  store,
  id,
}) {
  const checked = useRowSelected(store, id);

  return (
    <Checkbox
      checked={checked}
      onClick={(e) => e.stopPropagation()}
      onChange={(e) => store.setSelected(id, e.target.checked)}
    />
  );
});

Now, when one row is toggled, the flow is much simpler:

Click checkbox
      ↓
store.setSelected(id)
      ↓
notifyRow(id)
      ↓
RowCheckboxCell for that row
      ↓
Only that row re-renders

Most importantly, none of this touches apiRef.current.state.rowSelection.

Which means DataGrid's selectedIdsLookupSelector never changes.

And since that selector doesn't change, useGridVirtualScroller has no reason to react to the checkbox change either.

The end result, when one checkbox is toggled:

  • RowCheckboxCell for that row re-renders;
  • no other row needs to re-render;
  • MuiDataGridVirtualScrollerRenderZone isn't triggered by a checkbox change alone;
  • selection state is still available for bulk actions.

For the header checkbox, we still need to know whether any row is selected. For that, the header can subscribe to the store's getVersion().

With this approach, selection state becomes the application's responsibility, not DataGrid's.

And since our actual requirements for this checkbox were quite simple, this approach felt more sensible than forcing selection state through DataGrid's internal mechanism.

Conclusion

The core issue was that a change in selection state triggered useGridVirtualScroller's subscription, which in turn re-ran the rendering process for every row in the render zone.

What's interesting about this case is that a large dataset does make the effect worse, but it isn't the sole root cause. With a larger page size, more rows are visible in the DOM, and more work has to happen every time the render zone gets triggered.

That's why the delay was still noticeable even on a high-spec laptop once the rows-per-page count went up. Our workaround wasn't a modification to MUI DataGrid internals, it was decoupling our app's actual requirements from DataGrid's built-in selection mechanism.

When chasing a performance issue in a complex React component like DataGrid, looking at what's actually re-rendering is usually far more useful than guessing based on data size alone.

If you're using MUI DataGrid and running into something similar, I hope this post helps as a starting reference for investigating with React Profiler before assuming the data size is the only culprit.