Skip to content

[DataGrid] Add support for replacing rows instead of merging updates - #23323

Open
jvskriubakken wants to merge 16 commits into
mui:masterfrom
jvskriubakken:feat/update-rows-replace-action
Open

[DataGrid] Add support for replacing rows instead of merging updates#23323
jvskriubakken wants to merge 16 commits into
mui:masterfrom
jvskriubakken:feat/update-rows-replace-action

Conversation

@jvskriubakken

@jvskriubakken jvskriubakken commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Resolves #22606

Problem

apiRef.current.updateRows() always merges: the stored row is rebuilt as a new object from the old row plus the update. That makes three things impossible:

  • Keeping object identity — getRow(id) never returns the object you passed in.
  • Keeping #private fields of class rows — a merged copy keeps the prototype but fails the brand check, so any method touching a private field throws.
  • Removing a field — merging can only add or overwrite.

For apps whose rows are class instances (typically built from a backend JSON response), there was no way to tell the Grid "store this object, as-is".

Solution

A new replace update, passed as an envelope object rather than a marker on the row itself:

apiRef.current.updateRows([{ _action: 'replace', row: myInstance }]);

The Grid stores row verbatim: apiRef.current.getRow(id) === myInstance. A replace is never a merge — fields missing from row are removed from the stored row.

The envelope was chosen over marking the row (row._action = 'replace', mirroring the existing delete action) so the user's object is never mutated: no hidden-class churn, frozen/sealed instances work, no marker can leak onto a long-lived object and turn a later innocent update into a replace, and it types cleanly as a discriminated union.

Why processRowUpdate() matters here

This is the part worth understanding before using the feature.
updateRows() is not the only code path that writes rows. Cell editing, row editing and clipboard paste also persist rows — and they build a plain-object draft of the edited row internally. If you don't intervene, that draft goes through the merge path and your class instance is silently demoted to a copy: prototype survives, identity and #private fields do not. The failure is delayed and confusing — the row looks fine until some method that reads a private field throws.

The Grid never persists an edit behind your back, though: it always hands the draft to processRowUpdate(newRow, oldRow, params) and stores whatever you return. So processRowUpdate() is the single place where you convert the draft back into a real instance, and it can now return the replace envelope:

<DataGrid
  processRowUpdate={async (newRow, oldRow) => {
    const json = await saveOnServer(newRow);
    return { _action: 'replace', row: MyRowClass.fromJSON(json) };
  }}
/>

With dataSource, the equivalent hook is dataSource.updateRow() — its return value is funneled into the same code path, so it can return the envelope too.
So, when is processRowUpdate() needed?

  • Not needed if updateRows() is your only write path (server push, polling, streamed updates, editing disabled). Read-only features — sorting, filtering, grouping, tree data, aggregation, export, printing — never rewrite stored rows, so identity holds everywhere.
  • Needed as soon as cell editing, row editing or clipboard paste is enabled and you care about identity/#private fields. Without it, edits go through the merge path and demote the instance.
    One-sentence contract: identity survives everywhere, provided every write goes through either a replace envelope or a processRowUpdate() / dataSource.updateRow() that returns one.

Caveats (documented)

  • The replacement must be a different object from the one currently stored. Rows are memoized on the row prop, so replacing a row with the same, mutated instance may not repaint it.
  • Within one updateRows() call, make the replace the last update for a given id. Partial updates following it are merged onto the replacement, which keeps its prototype but is a new object. A dev-only warning points this out.

Changes

  • gridRows.ts: new GridRowReplaceUpdate<R> ({ _action: 'replace'; row: R });`` GridRowModelUpdate._action stays 'delete', so the two form a discriminated union.
  • gridRowsUtils.ts: isReplaceUpdate() type guard and getReplaceRow() unwrapper (throws a documented error if row is missing); updateCacheWithNewRows() unwraps the envelope in the dedup loop and tracks replaced ids in a Set, never on the row; extracted mergeRowUpdate() for the prototype-preserving merge.
  • updateRows(), updateNestedRows() and updateNonPivotRows() accept the new update type; pinned rows (computeRowsUpdates), pivoting, tree data and dataSource paths all handle it.
  • processRowUpdate return type widened to R | GridRowReplaceUpdate<R> | Promise<…>.
  • Clipboard import unwraps the envelope so the clipboardPasteEnd event payload exposes the stored row, not the wrapper.
  • Docs: new section in row-updates.md and "Replacing the row instead of merging it" in editing/persistence.md, cross-linked.

Performance

The non-replace hot path is unchanged apart from one _action === 'replace' comparison. The replace path is cheaper than a merge: it stores a reference instead of allocating and copying every field. The envelope is one short-lived allocation per replace. Dev warnings are NODE_ENV-guarded.

Tests

  • rows.DataGrid / rows.DataGridPro: replace semantics, verbatim storage, field removal, batch ordering (replace cancels a queued delete, resets accumulated updates), the caller's object is never mutated, pinned rows, tree data.
  • rows.DataGrid: inline edit whose processRowUpdate returns the envelope keeps identity and #private state.
  • clipboard.DataGridPremium: paste stores the returned instance verbatim; the event carries the unwrapped row.
  • dataSource.DataGrid / dataSource.DataGridPro: getRows() rows are stored verbatim; updateRow() returning the envelope preserves identity, including the Pro handleEditRow path; direct updateRows() replace with an active dataSource.
  • dataSourceLazyLoader.DataGridPro (browser): with infinite scroll, a replaced row survives fetching further chunks.

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 🔺+255B(+0.06%) 🔺+88B(+0.07%)
@mui/x-data-grid-pro 🔺+195B(+0.04%) 🔺+74B(+0.05%)
@mui/x-data-grid-premium 🔺+748B(+0.10%) 🔺+236B(+0.11%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 0B(0.00%) 0B(0.00%)
@mui/x-scheduler-premium 0B(0.00%) 0B(0.00%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@JCQuintas JCQuintas added scope: data grid Changes related to the data grid. type: new feature Expand the scope of the product to solve a new problem. labels Aug 12, 2026

@arminmeh arminmeh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@jvskriubakken
Thanks for the contribution and the details provided.

I have made some adjustments, so it looks pretty much ok for me now.
The only remaining thing is the type change. Let's see what others think.

export type GridRowModel<R extends GridValidRowModel = GridValidRowModel> = R;

export type GridUpdateAction = 'delete';
export type GridUpdateAction = 'delete' | 'replace';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This type is not used internally anymore, but it is exported, so we have to keep it.
Not adding 'replace' would be inaccurate, but adding it would create a breaking change for users.
Depending on the usage, additional checks would be needed to confirm that the update is of a specific type.

Suggestions @MBilalShafi @JCQuintas @jvskriubakken?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was the original suggestion by Claude on how to extend this type:

type GridRowModelUpdate<R extends GridValidRowModel = GridValidRowModel> =
  | (Partial<R> & { id: GridRowId; _action?: never })
  | { id: GridRowId; _action: 'delete' }
  | (R & { _action: 'replace' });   // NEW — full row required

But I am not sure if that makes it a less "breaking change".

newRow: R,
oldRow: R,
params: { rowId: GridRowId },
) => Promise<R | GridRowModelReplace<R>> | R | GridRowModelReplace<R>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This could also break for people using the prop's return value for further processing, but that is very unlikely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: data grid Changes related to the data grid. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[data grid] Add explicit _action: 'replace' to updateRows for identity-preserving row swaps

3 participants