I am rendering a table with the react-table library in its version 7.6.2.
The table (is a paginated table) has the functionality to add or delete rows, as well as edit cell values. Each time the user adds a new row or edits a cell, the cell or row is updated with a blue background.
Up to this point, everything works correctly. The problem comes when deleting a row. After deleting the row from the data structure, the row is removed from the table, but the color of the row remains in the table until the pagination is updated.
My production app works with redux, but I've created a simplified sandbox to reproduce the bug.
I have validated that tableData is updated correctly.
import React, { useState, useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import Notifier from 'components/common/Notifier'
import ContextMenu from './ContextMenu'
import CustomTable, { header } from './customTable'
import colorSelector from './coloring'
const SequenceTable = ({ tableData = [], sTool = null, sPart = null, onRowSelect = () => {}, onUpdateTableData = () => {}, handlePartChange = () => {} }) => {
const columns = useMemo(() => header, [])
const [skipPageReset, setSkipPageReset] = useState(false)
const [selectedRow, setSelectedRow] = useState(null)
const [mousePos, setMousePos] = useState({ x: null, y: null })
const [contextRow, setContextRow] = useState(null)
const updateMyData = (rowIndex, columnId, value) => {
setSkipPageReset(true)
onUpdateTableData({ rowIndex: rowIndex, columnId: columnId, value: value })
}
const handleContextMenuOpen = (event, row) => {
event.preventDefault()
setMousePos({ x: event.clientX, y: event.clientY })
setContextRow(row.values)
}
const handleContextMenuClose = () => {
setContextRow(null)
setMousePos({ x: null, y: null })
}
useEffect(() => {
onRowSelect(selectedRow)
}, [selectedRow])
useEffect(() => {
if (tableData != null && tableData.length !== 0) handlePartChange(sTool, tableData[0])
}, [sPart])
useEffect(() => setSkipPageReset(false), [sTool, sPart])
return (
<React.Fragment>
<CustomTable
columns={columns}
data={tableData}
updateMyData={updateMyData}
openContextMenu={handleContextMenuOpen}
setSelectedRow={setSelectedRow}
skipPageReset={skipPageReset}
getCellProps={cellInfo => colorSelector(cellInfo.value ? cellInfo.value.colorCode : -1)}
/>
<ContextMenu mousePos={mousePos} row={contextRow} onClose={() => handleContextMenuClose()} />
<Notifier />
</React.Fragment>
)
}
SequenceTable.propTypes = {
tableData: PropTypes.array,
sTool: PropTypes.string,
sPart: PropTypes.string
}
export default SequenceTable
import React, { useEffect } from 'react'
import { useTable, usePagination, useSortBy, useRowSelect } from 'react-table'
import Table from 'react-bootstrap/Table'
import ClickAndHold from 'components/common/ClickAndHold'
import EditableCell from './EditableCell'
import Pagination from './Pagination'
const defaultColumn = { Cell: EditableCell }
const CustomTable = ({ columns, data, updateMyData, openContextMenu, setSelectedRow, skipPageReset, getCellProps = () => ({}) }) => {
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
page,
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
selectedFlatRows,
state: { pageIndex, pageSize, selectedRowIds }
} = useTable(
{
columns,
data,
stateReducer: (newState, action) => {
if (action.type === 'toggleRowSelected') {
newState.selectedRowIds = {
[action.id]: true
}
}
return newState
},
defaultColumn,
autoResetPage: !skipPageReset,
updateMyData,
initialState: {
sortBy: [
{
id: 'id',
desc: false
}
],
hiddenColumns: ['id']
}
},
useSortBy,
usePagination,
useRowSelect
)
useEffect(() => {
if (selectedFlatRows.length !== 0) setSelectedRow(selectedFlatRows[0].original)
}, [setSelectedRow, selectedRowIds])
return (
<React.Fragment>
<Table responsive striped bordered hover size="sm" {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps()}>{column.render('Header')}</th>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{page.map((row, i) => {
prepareRow(row)
return (
<ClickAndHold id={i} elmType={'tr'} onHold={e => openContextMenu(e, row)} {...row.getRowProps()} onContextMenu={e => openContextMenu(e, row)}>
{row.cells.map(cell => {
return <td {...cell.getCellProps([getCellProps(cell)])}>{cell.render('Cell')}</td>
})}
</ClickAndHold>
)
})}
</tbody>
</Table>
<Pagination
canPreviousPage={canPreviousPage}
canNextPage={canNextPage}
pageOption={pageOptions}
pageCount={pageCount}
gotoPage={gotoPage}
nextPage={nextPage}
previousPage={previousPage}
setPageSize={setPageSize}
pageIndex={pageIndex}
pageSize={pageSize}
/>
</React.Fragment>
)
}
export default CustomTable
My custom cell component:
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import InputBase from '@material-ui/core/InputBase'
import { openSnackbar } from 'components/common/Notifier'
const EditableCell = (
{
value: initialValue,
row: { index },
column: { id },
updateMyData // This is a custom function that we supplied to our table instance
},
{ literal = () => '' }
) => {
const [isValid, setIsValid] = useState(true)
const [value, setValue] = useState(initialValue)
const [errorMsg, setErrorMsg] = useState('')
const [edited, setEdited] = useState(false)
const onChange = e => {
e.persist()
setEdited(true)
let valid = true
if (value.type === 'bool' && e.target.value !== 'true' && e.target.value !== 'false') {
console.log('mustBeBoolean')
valid = false
}
if (value.type === 'number' && isNaN(e.target.value)) {
console.log('mustBeNumeric')
valid = false
}
setValue(oldVal => {
return Object.assign({}, oldVal, {
value: e.target.value
})
})
setIsValid(valid)
}
const onBlur = () => {
if (isValid) {
if (edited) updateMyData(index, id, value.value)
} else {
setValue(initialValue)
value.value != null && openSnackbar({ message: errorMsg, apiResponse: 'error' })
}
setEdited(false)
}
useEffect(() => {
setValue(initialValue)
}, [initialValue])
return <InputBase disabled={!value.editable} value={value.value != null ? value.value : ''} onChange={onChange} onBlur={onBlur} />
}
EditableCell.contextTypes = {
literal: PropTypes.func
}
export default EditableCell
My data model is as follows:
const data =[{
"id": 1,
"absltBendingStep": {
"value": 2,
"editable": false,
"colorCode": -1,
"type": "number"
},
"rltvBendingStep": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"circInterpolation": {
"value": null,
"editable": true,
"colorCode": -1,
"type": "bool"
},
"shape": {
"value": null,
"editable": true,
"colorCode": -1,
"type": "bool"
},
"xClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"tip": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"headUpperClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"headLowerClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"duPlate": {
"value": 15.75706,
"editable": true,
"colorCode": -1,
"type": "number"
},
"xConf": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"yConf": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"angle": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"description": {
"value": "15.8",
"editable": false,
"colorCode": -1,
"type": "string"
},
"upperClamp": {
"value": 0,
"editable": false,
"colorCode": -1,
"type": "number"
},
"time": {
"value": 0,
"editable": false,
"colorCode": -1,
"type": "number"
},
"observations": {
"value": "",
"editable": false,
"colorCode": -1,
"type": "string"
}
},
{
"id": 2,
"absltBendingStep": {
"value": 3,
"editable": false,
"colorCode": -1,
"type": "number"
},
"rltvBendingStep": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"circInterpolation": {
"value": null,
"editable": true,
"colorCode": -1,
"type": "bool"
},
"shape": {
"value": null,
"editable": true,
"colorCode": -1,
"type": "bool"
},
"xClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"tip": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"headUpperClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"headLowerClamp": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "string"
},
"duPlate": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"xConf": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"yConf": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"angle": {
"value": null,
"editable": false,
"colorCode": -1,
"type": "number"
},
"description": {
"value": "",
"editable": false,
"colorCode": -1,
"type": "string"
},
"upperClamp": {
"value": 0,
"editable": false,
"colorCode": -1,
"type": "number"
},
"time": {
"value": 0,
"editable": false,
"colorCode": -1,
"type": "number"
},
"observations": {
"value": "",
"editable": false,
"colorCode": -1,
"type": "string"
}
}]
The main cause of this issue is keys. To learn more about how to use keys, you can check out React's official documentation here: https://reactjs.org/docs/lists-and-keys.html#keys
The main cause of this inconsistency is the code here
<tbody {...getTableBodyProps()}>
{page.map((row, i) => {
prepareRow(row)
return (
<ClickAndHold id={i} elmType={'tr'} onHold={e => openContextMenu(e, row)} {...row.getRowProps()} onContextMenu={e => openContextMenu(e, row)}>
{row.cells.map(cell => {
return <td {...cell.getCellProps([getCellProps(cell)])}>{cell.render('Cell')}</td>
})}
</ClickAndHold>
)
})}
</tbody>
The ClickAndHold
component is passed the props from row.getRowProps()
. row.getRowProps()
returns an object that contains a key that looks something like row_0
. Now, this key is dependent upon the row's position in the table. Suppose there were five rows, then their keys would be row_0
, row_1
, row_2
, row_3
, and row_4
. If you deleted the 4th row (with key row_3
), the fifth row (with key row_4
) would get the fourth row's key. Suppose that you actually deleted the fourth row, then the keys would look like this: row_0
, row_1
, row_2
, row_3
. So, now, the fifth row (which previously had key row_4
, but now has key row_3
), has fourth row's key. Thus, when react re renders your tree, it'll pass the fourth row's props to fifth row. This means that if the fourth row had blue background, then fifth row will also have blue background. I know this is a handful, but I hope I'm making sense here.
To get around this issue, you need to pass a unique key to the row. This unique key should ideally come from the data that you're rendering. If I look at your data, you have an id
that is unique. So, use this id
as a key for ClickAndHold
component. Summarizing everything, to resolve this issue, you need to edit the code as
<tbody {...getTableBodyProps()}>
{page.map((row, i) => {
prepareRow(row)
return (
<ClickAndHold id={i} elmType={'tr'} onHold={e => openContextMenu(e, row)} {...row.getRowProps()} key={row.original.id} onContextMenu={e => openContextMenu(e, row)}>
{row.cells.map(cell => {
return <td {...cell.getCellProps([getCellProps(cell)])}>{cell.render('Cell')}</td>
})}
</ClickAndHold>
)
})}
The row
object inside the page
list contains an original
object that contains your data. So, you simply need to use id
from your custom data, and use it as key
. You need to pass key
after {...row.getRowProps()}
to override the key returned by row.getRowProps()
.
I've tested this in your codesandbox, and over there you simply need to edit the tr
component found in line 85 inside CustomTable.jsx
in this way.
<tr
id={i}
{...row.getRowProps()}
key={row.original.id}
onContextMenu={(e) => openContextMenu(e, row)}
>
I hope that's helpful.
Another suggestion, in your code for adding new row, you're chaning all the ids that come after the newly added row. This is code for reference.
setTableData((sequence) => {
const newData = sequence.reduce((acc, step) => {
if (incrementId) {
step.id = step.id + 1;
acc.push(step);
} else {
acc.push(step);
}
if (step.id === nextToId) {
newStep.id = newStep.id + 1;
acc.push(newStep);
incrementId = true;
}
return acc;
}, []);
return newData;
});
This will cause inconsistency because when you change id
which is used as key for a row, on next re render, react will pass props to the newly added row that belonged to the previous row that the newly added row replaced. To learn more about this, check out this article: https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318. What I'm trying to say is, keys should be unique for every component inside the list, and if a component is added or deleted, any other component cannot take its key. You can use uuid for generating unique keys. Take a look here for how to use uuid https://www.npmjs.com/package/uuid.
Essentially, you need to be careful with dealing with keys, otherwise you risk severely degrading the performance of your application, or messing up the state of your component tree.
UPDATE
Sorry, but I was wrong about the root cause of this issue. Though it's true that there's a problem with the way you're using keys, the background color issue is not solely due to it. In fact, what causes background color to not change is because you set background-color: none
. background-color
doesn't have a property called none. So, this is an invalid CSS property, and it doesn't get updated in the DOM. This causes previously valid background-color to linger around, thus causing the issue. To fix this, you probably want to set background-color: unset
or background-color: white
when you want to remove the blue background. Hope that helps!