Fixing the 'Ghost Changes' Bug in Bulk Edit Tables
Deep dive into debugging a tricky state management issue where the system detected changes that didn't actually exist, and how comparing with original values solved the problem.
The Problem
Today I encountered an interesting bug in the bulk edit table feature. Users complained that the "Save" button showed "1 changed row" even though they only typed text in an input field and then deleted it all back to empty. There should be no changes since the value is the same as the initial value, but the system still detected changes.
The visual clue was clear: there was no "blue dot" in the corner of any field (which indicates a changed field), but the change counter still showed 1 change.
Root Cause Analysis
After investigation, it turned out the problem was in the state management logic of the custom hook useBulkTableEdit. This hook tracks changes by storing all fields that have ever been updated into an object called editState:
// Before fix
const updateField = (itemId, field, value) => {
dispatch({ type: "UPDATE_FIELD", itemId, field, value });
};The reducer is very simple: whenever there's an UPDATE_FIELD, it immediately inserts into editState without checking whether the value is different from the original or not.
case 'UPDATE_FIELD':
return {
...state,
editState: {
...state.editState,
[action.itemId]: {
...state.editState[action.itemId],
[action.field]: action.value,
},
},
}The problem: This hook tracks "fields that have been touched", not "fields that have changed".
So if a user types "test" and then deletes it all back to empty (returning to the original value), that field still remains in editState with the same value as the original. The counter changesCount is calculated from Object.keys(editState).length, which counts ANY item in editState regardless of whether it's actually different.
The Solution
The solution turned out to be simple but required strategic placement. Since the hook doesn't have access to original values, I moved the comparison logic to the component level - where we have access to the original item data.
The Pattern I Applied
// In editable components
onChange={(event) => {
const newValue = event.target.value
const originalValue = item[field]
// Compare and decide
if (newValue === originalValue) {
bulkEdit.removeField(item._id, field) // Remove from editState
} else {
bulkEdit.updateField(item._id, field, newValue) // Keep in editState
}
// Validation still runs
onValidateField(newValue)
}}Files Modified
I applied this pattern to all editable components:
-
Reusable Components (for destinations):
EditableTextCell.tsx- Text input fieldsEditableCustomerCell.tsx- Dropdown select
-
Inline Edit Components:
customers.tsx- Name & email fieldsoperators.tsx- Name field
Key Learnings
1. State Management Principle
Track actual changes, not interactions. There's a big difference between "user touched this field" vs "user changed this field's value".
2. Data Access Pattern
If a hook doesn't have enough context (in this case, original values), push the logic to the layer that does - in this case, the components that render the data.
3. Defensive Comparison
Always compare with the original before updating state:
// Good pattern
if (newValue === originalValue) {
removeChange();
} else {
recordChange();
}
// Anti-pattern
recordChange(); // Always record without checking4. DRY via Components
By creating reusable EditableTextCell and EditableCustomerCell, I only needed to fix once for the destinations page. For inline edits in customers & operators, I had to fix them one by one because they don't use shared components.
Testing Strategy
After the fix, I tested with these scenarios:
- ✅ Type text → delete all → No changes detected
- ✅ Type text → edit → change again → Only shows changed if different
- ✅ Change dropdown → revert → No changes detected
- ✅ Blue dot indicators only appear for fields that differ from original
- ✅ Change counter is accurate according to the number of actual changes
All passed! 🎉
Conclusion
This bug reminded me that user interaction ≠ data change. In good UI/UX, the system must be smart enough to distinguish between "user is exploring/typing" vs "user made actual changes worth saving".
Takeaway: When building edit interfaces, always validate actual changes against original values, not just track interactions. Your users will thank you for not bothering them with false "unsaved changes" warnings! 🚀
Leave a comment