Why does using splice work but filter doesn’t when updating React state based on another state variable?
I’m working with React and have a stateful list of stopwatches. When I try to remove an item from the list based on a selected index, I’ve noticed that two different approaches behave differently:
Working Approach
removeStopwatch={() => {
setStopwatches(s => {
const newArray = [...s];
newArray.splice(selectedStopwatchIndex, 1);
return newArray;
});
}}
Non-Working Approach
removeStopwatch={() => {
setStopwatches(s => {
const newArray = [...s];
return newArray.filter((_, i) => i !== selectedStopwatchIndex);
});
}}
The first approach using splice works correctly and removes the selected stopwatch. However, the second approach using filter doesn’t work, even though they seem functionally equivalent.
I’ve verified that selectedStopwatchIndex has the correct value when the remove button is clicked. What am I missing about how React state updates work in this context?
Code Context
Here’s the relevant component structure:
function MainScreen() {
const [stopwatches, setStopwatches] = useState([
new Stopwatch('A'),
new Stopwatch('B')
]);
const [selectedStopwatchIndex, setSelectedStopwatchIndex] = useState(-1);
let selectedStopwatch = selectedStopwatchIndex == -1
? null
: stopwatches[selectedStopwatchIndex];
return (
<>
<StopwatchList
stopwatches={stopwatches}
setStopwatches={setStopwatches}
selectedStopwatchIndex={selectedStopwatchIndex}
setSelectedStopwatchIndex={setSelectedStopwatchIndex}
/>
<StopwatchDetails
stopwatch={selectedStopwatch}
removeStopwatch={() => {
// The problematic function
}}
/>
</>
);
}
function StopwatchDetails({ stopwatch, removeStopwatch }) {
const [time, setTime] = useState('0:00:00');
useEffect(() => {
if (!stopwatch) return;
const interval = setInterval(() => {
setTime(stopwatch.getElapsedTimeFormatted());
}, 100);
return () => {
clearInterval(interval);
}
}, [stopwatch]);
return (
<>
<p>
{stopwatch == null
? 'Info about the currently selected stopwatch will appear here!'
: `Time: ${time}`}
</p>
<button onClick={removeStopwatch}>Remove</button>
</>
);
}
I’ve also created a CodeSandbox that demonstrates this issue: https://codesandbox.io/p/sandbox/recursing-napier-ly4td9
The filter approach doesn’t work due to a closure issue where the removeStopwatch function captures a stale value of selectedStopwatchIndex, while the splice approach appears to work because it relies on the current state value within the updater function.
Contents
- Understanding the Closure Problem
- Why Splice Appears to Work
- Why Filter Fails
- Solutions to Fix the Issue
- Best Practices for React State Updates
Understanding the Closure Problem
When you pass the removeStopwatch function from MainScreen to StopwatchDetails, it captures the current value of selectedStopwatchIndex in its closure. Here’s what happens:
// In MainScreen component
<StopwatchDetails
stopwatch={selectedStopwatch}
removeStopwatch={() => {
// This function captures selectedStopwatchIndex at creation time
setStopwatches(s => {
const newArray = [...s];
newArray.splice(selectedStopwatchIndex, 1); // Uses stale value
return newArray;
});
}}
/>
The problem is that selectedStopwatchIndex might change (become -1 or another value) between when the function is created and when it’s actually called by the button click in StopwatchDetails.
Why Splice Appears to Work
The splice approach seems to work because it uses the functional update form of setStopwatches:
setStopwatches(s => {
// 's' is the current state value
const newArray = [...s];
newArray.splice(selectedStopwatchIndex, 1); // Still uses stale value
return newArray;
});
However, this is misleading. The splice approach only appears to work because selectedStopwatchIndex happens to still be correct when the function executes, or because the error is less noticeable.
Why Filter Fails
The filter approach fails for the same reason - it uses the stale captured value:
removeStopwatch={() => {
setStopwatches(s => {
const newArray = [...s];
return newArray.filter((_, i) => i !== selectedStopwatchIndex); // Stale value
});
}}
The difference is more apparent with filter because:
- Filter processes the entire array
- If
selectedStopwatchIndexis -1 (no selection), filter removes nothing - If
selectedStopwatchIndexis outdated, it removes the wrong element
Solutions to Fix the Issue
Solution 1: Pass the Index Directly to the Handler
function MainScreen() {
// ... existing state
const handleRemoveStopwatch = (indexToRemove) => {
setStopwatches(s => {
return s.filter((_, i) => i !== indexToRemove);
});
};
return (
<>
<StopwatchList
stopwatches={stopwatches}
setStopwatches={setStopwatches}
selectedStopwatchIndex={selectedStopwatchIndex}
setSelectedStopwatchIndex={setSelectedStopwatchIndex}
/>
<StopwatchDetails
stopwatch={selectedStopwatch}
removeStopwatch={() => handleRemoveStopwatch(selectedStopwatchIndex)}
/>
</>
);
}
Solution 2: Use useCallback with Dependencies
function MainScreen() {
// ... existing state
const handleRemoveStopwatch = useCallback(() => {
if (selectedStopwatchIndex === -1) return;
setStopwatches(s => s.filter((_, i) => i !== selectedStopwatchIndex));
}, [selectedStopwatchIndex]);
return (
<>
<StopwatchList
stopwatches={stopwatches}
setStopwatches={setStopwatches}
selectedStopwatchIndex={selectedStopwatchIndex}
setSelectedStopwatchIndex={setSelectedStopwatchIndex}
/>
<StopwatchDetails
stopwatch={selectedStopwatch}
removeStopwatch={handleRemoveStopwatch}
/>
</>
);
}
Solution 3: Include Index in StopwatchDetails
function StopwatchDetails({ stopwatch, index, removeStopwatch }) {
// ... existing logic
return (
<>
<p>
{stopwatch == null
? 'Info about the currently selected stopwatch will appear here!'
: `Time: ${time}`}
</p>
<button onClick={() => removeStopwatch(index)}>Remove</button>
</>
);
}
// In MainScreen:
<StopwatchDetails
stopwatch={selectedStopwatch}
index={selectedStopwatchIndex}
removeStopwatch={(index) => setStopwatches(s => s.filter((_, i) => i !== index))}
/>
Best Practices for React State Updates
1. Avoid Closure Closures
Never pass functions that capture state values directly to child components. Instead:
- Pass the value as a parameter
- Use
useCallbackwith proper dependencies - Create the handler in the child component
2. Prefer Immutability
Both splice and filter can be used immutably, but filter is generally preferred for its clarity:
// Good - immutable filter
setStopwatches(prev => prev.filter((_, i) => i !== index));
// Good - immutable spread + splice (more verbose)
setStopwatches(prev => {
const newArray = [...prev];
newArray.splice(index, 1);
return newArray;
});
3. Consider Performance for Large Arrays
As noted in the research, for large arrays, both approaches have O(n) complexity, but filter processes the entire array while splice only processes the remaining elements after the removal point.
4. Use Immer for Complex Updates
For complex state updates, consider using Immer:
import { produce } from 'immer';
setStopwatches(prev =>
produce(prev, draft => {
draft.splice(selectedStopwatchIndex, 1);
})
);
Conclusion
The core issue is a closure problem where the removeStopwatch function captures a stale value of selectedStopwatchIndex. Both splice and filter approaches would work correctly if they used the current state value, but the closure captures the value at function creation time.
Key takeaways:
- Always pass current state values as parameters to handlers
- Use
useCallbackwith proper dependencies when passing handlers - Filter is generally preferred for its clarity and immutability
- Be aware of closure issues when passing functions between components
The most reliable solution is to pass the index directly to the handler function or use useCallback with the correct dependencies to ensure you’re always working with the current state values.