Summary
In dataSource mode, the only public ways to push a server-confirmed row change into the grid — apiRef.updateRows([row]), apiRef.updateNestedRows, and dataSource.editRow — all funnel through an internal helper that does roughly Object.assign({}, oldRow, newRow). This produces a plain object and discards the prototype of newRow. Consumers using class-based row models (with prototype getters or methods) lose the prototype after the merge.
The same root cause surfaces in two ways depending on how a column reads from the row:
- Prototype getter (column
field: "fullName" resolving to get fullName()) → cell renders blank, no error.
- Prototype method (column
valueGetter: (_, row) => row.getFullName()) → TypeError: row.getFullName is not a function thrown during render, which can take down the page.
The documented updateRows API silently discards the prototype of the row object passed in, which violates the reasonable expectation that the row read back via apiRef.getRow(id) matches the row that was written. There is also no alternative public API that preserves it.
Steps to reproduce
https://stackblitz.com/edit/xf4rluqa?file=src%2FRepro.tsx
class Person {
constructor(public id: number, public first: string, public last: string) {}
get fullName() { return `${this.last}, ${this.first}`; } // failure mode 1: blank cell
getFullName() { return `${this.last}, ${this.first}`; } // failure mode 2: thrown TypeError
}
const dataSource: GridDataSource = {
getRows: async () => ({
rows: [new Person(1, "Ada", "Lovelace"), new Person(2, "Alan", "Turing")],
rowCount: 2,
}),
};
const columns: GridColDef[] = [
{ field: "fullName", headerName: "Full name (getter)", width: 220 },
{
field: "fullNameMethod",
headerName: "Full name (method)",
width: 220,
valueGetter: (_, row) => row.getFullName(),
},
];
// On click:
apiRef.current.updateRows([new Person(1, "Augusta Ada", "Byron")]);
Current behavior
- The getter column renders an empty cell.
- The method column throws TypeError: row.getFullName is not a function.
- apiRef.getRow(1) returns a plain object — Object.getPrototypeOf(row) === Object.prototype. Both fullName and getFullName are gone.
Expected behavior
Both Full name cells of row 1 show "Byron, Augusta Ada". The row stays in place. Selection and rowCount are unaffected.
Context
Workarounds investigated and why they don't work
| Workaround |
Why it fails |
Controlled rows prop (mapped replacement in React state) |
Ignored by the grid when dataSource is set. |
apiRef.updateRows([{ id, _action: "delete" }, newRow]) (delete + insert) |
Bypasses the merge, but in dataSource mode the delete compacts the in-memory row store and the re-insert lands at the tail of the current page bucket, so all rows below the target visibly reorder. Selection on that row is lost (id transiently leaves the selection model). rowCount decrements between the two ops. |
Object.setPrototypeOf(apiRef.getRow(id), …) + Object.assign + force-render |
Touches private storage; fragile across versions. |
Move every derived value into a valueGetter and stop using methods/getters on the row |
See "Why valueGetter is not an adequate workaround" below. |
Why valueGetter is not an adequate workaround
The standard suggestion ("just compute the value in valueGetter") forces derived logic to live on the column definition rather than on the row model. In a codebase that uses class-based domain objects, that has real costs:
- Logic duplication / loss of reuse. A
Person.fullName (or any other computed property/method) is naturally consumed by many call sites — detail panes, dialogs, exports, tooltips, validation, tests. When the same computation has to be re-expressed as a valueGetter purely so the grid can render it, the column definition becomes a second source of truth that must be kept in sync with the model. With several columns and several row types this scales badly.
- Domain model leaks into UI tables. Computed properties belong on the entity (e.g.
Address.formatted, Inspection.isOverdue). Forcing them into valueGetters couples the grid to internals it shouldn't know about, and discourages the natural OO/DDD modelling pattern of putting behaviour on the data.
- Methods can't always be replaced by
valueGetter. A valueGetter only fires when MUI computes a cell value. Prototype methods used by renderCell, sorting comparators, custom row-level actions, cellClassName callbacks, etc., still call row.someMethod() directly — and silently break (or throw) the moment the prototype is gone.
- It hides, not fixes, the bug. Even with
valueGetter everywhere, apiRef.getRow(id) still returns a plain object. Anyone who reads back from the grid (say, to drive a detail pane from grid selection) is still working with a stripped row.
So valueGetter is a per-column, per-call-site bandage for a problem that lives in the row store itself. It does not generalise.
Suggested fix (in order of preference)
updateRows accepts _action: "replace" — swaps the row object outright (no Object.assign). Existing _action: "delete" precedent makes this a low-surface addition. Identity, position in the grouping/sort order, and selection are preserved because no delete intervenes.
dataSource.replaceRow(id, row) on the data source API — updates both the visible store and the cache without merging.
Either keeps updateRows semantics intact for the common case (plain-object rows) while giving consumers with rich row models a single, supported way to push a server-confirmed update into the grid without losing the row's identity.
Why option 1 is cheap
The GridUpdateAction union (in @mui/x-data-grid/models/gridRows.ts) is currently:
export type GridUpdateAction = 'delete';
Extending it to 'delete' | 'replace' is a non-breaking, additive change with no new public API surface — just one more branch in the existing updateRows reducer that swaps the row object outright instead of Object.assign-merging it. No new method, no new type, no migration for existing callers (the default — omitted _action — keeps today's upsert/shallow-merge behavior).
Why this matters
Class-based row models are a natural fit for codebases that use rich domain objects on the client (DDD-style, computed properties via getters, methods on rows). The row model is the obvious place to host that behaviour because it's already consumed everywhere else. Today the grid quietly demands the opposite — that all such behaviour be re-expressed as plain-object data plus column-level valueGetters — purely because the only updateRows code path discards the prototype. An identity-preserving update primitive removes that mismatch and lets dataSource mode coexist with rich row models.
Your environment
npx @mui/envinfo
Don't forget to mention which browser you used.
Output from `npx @mui/envinfo` goes here.
Search keywords: dataSource updateRows
Order ID: 101811
Summary
In
dataSourcemode, the only public ways to push a server-confirmed row change into the grid —apiRef.updateRows([row]),apiRef.updateNestedRows, anddataSource.editRow— all funnel through an internal helper that does roughlyObject.assign({}, oldRow, newRow). This produces a plain object and discards the prototype ofnewRow. Consumers using class-based row models (with prototype getters or methods) lose the prototype after the merge.The same root cause surfaces in two ways depending on how a column reads from the row:
field: "fullName"resolving toget fullName()) → cell renders blank, no error.valueGetter: (_, row) => row.getFullName()) →TypeError: row.getFullName is not a functionthrown during render, which can take down the page.The documented
updateRowsAPI silently discards the prototype of the row object passed in, which violates the reasonable expectation that the row read back viaapiRef.getRow(id)matches the row that was written. There is also no alternative public API that preserves it.Steps to reproduce
https://stackblitz.com/edit/xf4rluqa?file=src%2FRepro.tsx
Current behavior
Expected behavior
Both Full name cells of row 1 show "Byron, Augusta Ada". The row stays in place. Selection and rowCount are unaffected.
Context
Workarounds investigated and why they don't work
rowsprop (mapped replacement in React state)dataSourceis set.apiRef.updateRows([{ id, _action: "delete" }, newRow])(delete + insert)dataSourcemode the delete compacts the in-memory row store and the re-insert lands at the tail of the current page bucket, so all rows below the target visibly reorder. Selection on that row is lost (id transiently leaves the selection model).rowCountdecrements between the two ops.Object.setPrototypeOf(apiRef.getRow(id), …)+Object.assign+ force-rendervalueGetterand stop using methods/getters on the rowvalueGetteris not an adequate workaround" below.Why
valueGetteris not an adequate workaroundThe standard suggestion ("just compute the value in
valueGetter") forces derived logic to live on the column definition rather than on the row model. In a codebase that uses class-based domain objects, that has real costs:Person.fullName(or any other computed property/method) is naturally consumed by many call sites — detail panes, dialogs, exports, tooltips, validation, tests. When the same computation has to be re-expressed as avalueGetterpurely so the grid can render it, the column definition becomes a second source of truth that must be kept in sync with the model. With several columns and several row types this scales badly.Address.formatted,Inspection.isOverdue). Forcing them intovalueGetters couples the grid to internals it shouldn't know about, and discourages the natural OO/DDD modelling pattern of putting behaviour on the data.valueGetter. AvalueGetteronly fires when MUI computes a cell value. Prototype methods used byrenderCell, sorting comparators, custom row-level actions,cellClassNamecallbacks, etc., still callrow.someMethod()directly — and silently break (or throw) the moment the prototype is gone.valueGettereverywhere,apiRef.getRow(id)still returns a plain object. Anyone who reads back from the grid (say, to drive a detail pane from grid selection) is still working with a stripped row.So
valueGetteris a per-column, per-call-site bandage for a problem that lives in the row store itself. It does not generalise.Suggested fix (in order of preference)
updateRowsaccepts_action: "replace"— swaps the row object outright (noObject.assign). Existing_action: "delete"precedent makes this a low-surface addition. Identity, position in the grouping/sort order, and selection are preserved because no delete intervenes.dataSource.replaceRow(id, row)on the data source API — updates both the visible store and the cache without merging.Either keeps
updateRowssemantics intact for the common case (plain-object rows) while giving consumers with rich row models a single, supported way to push a server-confirmed update into the grid without losing the row's identity.Why option 1 is cheap
The
GridUpdateActionunion (in@mui/x-data-grid/models/gridRows.ts) is currently:Extending it to
'delete' | 'replace'is a non-breaking, additive change with no new public API surface — just one more branch in the existingupdateRowsreducer that swaps the row object outright instead ofObject.assign-merging it. No new method, no new type, no migration for existing callers (the default — omitted_action— keeps today's upsert/shallow-merge behavior).Why this matters
Class-based row models are a natural fit for codebases that use rich domain objects on the client (DDD-style, computed properties via getters, methods on rows). The row model is the obvious place to host that behaviour because it's already consumed everywhere else. Today the grid quietly demands the opposite — that all such behaviour be re-expressed as plain-object data plus column-level
valueGetters — purely because the onlyupdateRowscode path discards the prototype. An identity-preserving update primitive removes that mismatch and letsdataSourcemode coexist with rich row models.Your environment
npx @mui/envinfoSearch keywords: dataSource updateRows
Order ID: 101811