Search code examples
reactjsreact-tablereact-table-v7

Update cell background color when deleting the row. (react-table)


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"
        }
    }]

Solution

  • 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!