0

Here are two React components:

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={() => {
          setStopwatches(s => {
            const newArray = [...s];
            newArray.splice(selectedStopwatchIndex, 1);
            return newArray;
            // return newArray.filter((_, i) => i !== selectedStopwatchIndex);
          });
        }}
      />
    </>
  );
}
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 apear here!'
          : `Time: ${time}`}
      </p>
      <button onClick={removeStopwatch}>Remove</button>
    </>
  );
}

StopwatchDetails shows the info about a stopwatch. It also takes as an argument a function that tells it what to do if the user presses the remove button.

MainScreen includes a StopwatchDetails. When I try to pass the remove stopwatch function like this:

removeStopwatch={() => {
  setStopwatches(s => {
    const newArray = [...s];
    newArray.splice(selectedStopwatchIndex, 1);
    return newArray;
  });
}}

It works as expected and the selected stopwatch is removed.

However, when I try to pass the remove stopwatch function like this:

removeStopwatch={() => {
  setStopwatches(s => {
    const newArray = [...s];
    return newArray.filter((_, i) => i !== selectedStopwatchIndex);
  });
}}

Nothing happens when I press the remove button. What am I missing?

If I console.log the value of selectedStopwatchIndex within the removeStopwatch arrow function, it correctly prints the index of the currently selected stopwatch

Here is a code sandbox that shows this issue in the MainScreen.jsx file: https://codesandbox.io/p/sandbox/recursing-napier-ly4td9

6
  • 1
    React has a certain way of recognizing a state change. Throw in a quick useEffect(() => console.log(stopwatches), [stopwatches]); to see if react recognizes the state change. If so, then throw the same thing in the StopwatchDetails component as well to see if it receives the updated state Commented Nov 6 at 21:18
  • 1
    What is the value of selectedStopwatchIndex when you press the remove button? Please edit to include complete details of your problem and the exact reproduction steps, along with any debugging logs and details from investigation. Commented Nov 6 at 23:29
  • 1
    I just put this in a codesandbox (simplifying a bit because I don't have the Stopwatch class at my disposal) and it worked as intended using newArray.filter(...) Commented Nov 6 at 23:45
  • @Thomas when I add that useEffect to the MainScreen component, it prints the stopwatches list whenever I press the remove button, but the list doesn't actually change. I can't put it in StopwatchDetails because StopwatchDetails isn't aware of the stopwatches list, it only knows about the current Stopwatch Commented Nov 7 at 2:21
  • 1
    I think we need to see a more complete code example to fully understand and reproduce any issues. Please edit to include a complete minimal reproducible example we can run ourselves. If possible, see if you can also create a running codesandbox.io (or similar) demo that reproduces the issue that readers could just run and inspect live. Commented Nov 7 at 17:39

2 Answers 2

3

Explanation

In isolation of the code you have presented, all that can really be said is that selectedStopwatchIndex is initialized to the value of -1 and there is a fundamental difference between what/how this -1 index value is used between Array.splice and how you use it in Array.filter.

Specifically, using the .splice method when a negative starting index is passed it is an offset index from the end of the array.

start

Zero-based index at which to start changing the array, converted to an integer.

  • Negative index counts back from the end of the array — if -array.length <= start < 0, start + array.length is used.
  • If start < -array.length, 0 is used.
  • If start >= array.length, no element will be deleted, but the method will behave as an adding function, adding as many elements as provided.
  • If start is omitted (and splice() is called with no arguments), nothing is deleted. This is different from passing undefined, which is converted to 0.

So passing -1 will actually effect a change in the array.

const index = -1;
const foo = [0,1,2,3,4,5];
const newFoo = [...foo]
newFoo.splice(index, 1);
console.log(JSON.stringify(newFoo)); // [0,1,2,3,4]

The last array element was removed!

Contrast this with using the .filter method where you are comparing each index value against selectedStopwatchIndex. Since all array indices are non-negative integers, none of them will ever equal -1 and omit the element.

const index = -1;
const foo = [0,1,2,3,4,5];
const newFoo = [...foo].filter((_, i) => i !== index);
console.log(JSON.stringify(newFoo)); // [0,1,2,3,4,5]

Here you see that since no index was equal to -1 and all elements were kept.

Technically there should no difference between the two implementations you are comparing, but the basic issue in your code doesn't appear to utilize selectedStopwatchIndex correctly. If/when you update selectedStopwatchIndex to match the index of the currently selected/active stopwatch, then the result should be the same between the two implementations.

const index = 3;
const foo = [0,1,2,3,4,5];

const newFoo = [...foo]
newFoo.splice(index, 1);
const newFoo2 = [...foo].filter((_, i) => i !== index);

console.log(JSON.stringify(newFoo)); //  [0,1,2,4,5]
console.log(JSON.stringify(newFoo2)); // [0,1,2,4,5]

Why didn't the array.filter work though?

In StopwatchList when rendering the list of stopwatches you assign the mapped array index to a data-x attribute

let listItems = stopwatches.map((stopwatch, i) => (
  <li
    key={i}
    data-stopwatch-id={i} // <--
    onClick={(e) => handleStopwatchItemClick(e)}
  >
    Stopwatch {i}
  </li>
));

and access this attribute value in a click handler

function handleStopwatchItemClick(e) {
  console.log(`Opening stopwatch ${e.currentTarget.dataset.stopwatchId}`);
  setSelectedStopwatchIndex(e.currentTarget.dataset.stopwatchId); // <--
}

The issue here is that e.currentTarget.dataset.stopwatchId is a string type, e.g. the values are "0", "1", etc... and the filter callback uses strict equality, so 1 === "1" will always evaluate false. Using loose equality will however attempt to coerce the type for comparison.

Example:

console.log('1 === "1"', 1 === "1"); // false!
console.log('1 == "1"', 1 == "1");   // true

Possible solutions available to you here are:

  • Convert e.currentTarget.dataset.stopwatchId back to a number before passing it back to the caller so the strict equality check will pass as expected.

    setSelectedStopwatchIndex(Number(e.currentTarget.dataset.stopwatchId));
    
  • Convert selectedStopwatchIndex to a number type at the time of comparison, ensuring the types match between operands.

    removeStopwatch={() => {
      setStopwatches((stopwatches) => {
        return stopwatches.filter(
          (_, i) => i !== Number(selectedStopwatchIndex)
        );
      });
    }}
    
  • Or forego the entire data-x attribute altogether and just simply pass the mapped array index, which will already be a type match.

    function handleStopwatchItemClick(i) {
      console.log(`Opening stopwatch ${i}`);
      setSelectedStopwatchIndex(i);
    }
    
    const listItems = stopwatches.map((stopwatch, i) => (
      <li
        key={i}
        onClick={() => handleStopwatchItemClick(i)}
      >
        Stopwatch {i}
      </li>
    ));
    

A word of advice here: maintaining your state type invariants, i.e. number for selectedStopwatchIndex, would help eliminate issues like this. Moving to Typescript makes this even easier as you'd get a build-time warning when attempting to pass any non-number type instead of a run-time error, or worse, no error at all and buggy code that doesn't crash like what you had.

Additionally

FWIW it's a general React anti-pattern to use the mapped array index also as the mapped React elements' keys, especially since you are mutating the array (i.e. adding and removing array elements). You should generally use a value that is intrinsic the mapped elements. GUIDs typically make great React keys. In your case here I'd suggest using a Stopwatch's name property.

An example rewrite of StopwatchList might look something like:

import { nanoid } from "nanoid";
import Stopwatch from "./Stopwatch";

function StopwatchList({
  stopwatches,
  setStopwatches,
  setSelectedStopwatchIndex,
}) {
  function handleAddStopwatch() {
    setStopwatches((stopwatches) =>
      // add new stop with generated GUID name value
      stopwatches.concat(new Stopwatch(nanoid()))
    );
  }

  return (
    <>
      <ul>
        {stopwatches.map((stopwatch, i) => (
          <li
            // Use stopwatch name GUID for React key
            key={stopwatch.name}
            // Call setSelectedStopwatchIndex directly and pass current index
            onClick={() => setSelectedStopwatchIndex(i)}
          >
            {/* Use stopwatch name GUID for easy identification */}
            Stopwatch {stopwatch.name}
          </li>
        ))}
      </ul>
      <button onClick={handleAddStopwatch}>Add stopwatch</button>
    </>
  );
}
Sign up to request clarification or add additional context in comments.

7 Comments

Thank you for pointing that out, I didn't realize that behaviour of splice. However, I'm still confused why nothing happens when using my solution that uses filter.
Oh, sorry, I thought I explained that part well enough. Like I said, based on the code in the post selectedStopwatchIndex has a value of -1, so in newArray.filter((_, i) => i !== selectedStopwatchIndex) all values of i will be greater than or equal to 0, i.e. 0 to newArray.length - 1, so i !== selectedStopwatchIndex will always evaluate to true and all array elements will be kept, i.e. the filtered array is the same as the source array with respects to the element values. Does this make sense?
In the last snippet example I was trying to demonstrate that when selectedStopwatchIndex is any value 0 or greater both implementations yield the same result.
Sorry, I should have mentioned that I'm changing the value of selectedStopwatchIndex to be an actual index in the list before trying to remove. StopwatchList includes code to set selectedStopwatchIndex based on user input
Thanks for the sandbox, with it I was able to track down the issue in your code causing the array filtering to not work as expected, even with updated selectedStopwatchIndex state. Your code was updating selectedStopwatchIndex to string values and these will never be strictly equal to their number-type counterparts. Please see the added section in my answer.
Thank you for your help, your explanation helped me a lot
Certainly! Happy to help. Cheers.
0

// import everything here

// ...

function MainScreen() {

  // keep these the same. 
  const [stopwatches, setStopwatches] = useState([
    new Stopwatch('A'),
    new Stopwatch('B')
  ]);

  
  // do not use -1 for special meaning. use null instead.
  const [selectedStopwatchIndex, setSelectedStopwatchIndex] = useState(null);


  // this is a computed value.so we will want to use useMemo here
  // to not change this every rerender.
  // only when the states needed for calculation changes shall this change.
  const selectedStopwatch = useMemo(
    ()=>{
      return selectedStopwatchIndex === null
        ? null
        : stopwatches[selectedStopwatchIndex]
      }, 
    [selectedStopwatchIndex, stopWatches]
    );
 
  // you will have to let the stopwatch detail props take null properties 
  // when nothing is selected yet. looking at the stopwatch detail
  // component you have now,it handles the case where the selected
  // stopwatch is null, so no need to change that.

  return (
    <>
      <StopwatchList
        stopwatches={stopwatches}
        setStopwatches={setStopwatches}
        selectedStopwatchIndex={selectedStopwatchIndex}
        setSelectedStopwatchIndex={setSelectedStopwatchIndex}
      />
      <StopwatchDetails
        stopwatch={selectedStopwatch}
        removeStopwatch={() => {
          // nothing to remove
          if (!selectedStopwatch) { return; }
 
          setStopwatches(s => {
            // remove only the selected stopwatch.
            // filter second parameter is a positive integer of real indexes, so
            // it will never equal -1, compare by reference instead.
            const newStopWatchList = s.filter((sw) => sw !== selectedStopwatch);
            return newStopWatchList;
          });
        // important so the user has to select another stopwatch.
        // this will cause 2 rerenders though.
        // consider using an object state or useReducer
        // to maintain just one state so we do not have to rerender 
        //this  again.
          setSelectedStopwatchIndex(null);
        }}
      />
    </>
  );
}

Most errors happened at the removeStopwatch property. first, you did not put account for cases where there are no stopwatch selected, second, you used `-1` as index to indicate no selected stopwatch when you should really be using null in this case to avoid bugs like these.

what i changed was i made the index when no selected stopwatch be null and the selected stopwatch be computed value that depends on the selected index (the useMemo part). second, i added a guard to handle the cases where there is no selected stopwatch, therefore we would have nothing to remove (hence, the check if (!selectedStopwatch)). third, comparing by stopwatch reference helps against race conditions where the selected index changed before react could run state setter factories. making sure we remove the correct stopwatch.

all these changes will make sure the code runs correctly as intended.

3 Comments

Thank you, all your changes to my code make sense. I'd still be really curious to know why the version that I wrote didn't work
Edited my answer, now it contains explaination on why the original code did not work... (i cannot fit the whole explaination in a comment...)
This was helpful, thank you!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.