Hombre Lobo
Hombre Lobo

Reputation: 69

Why does calling one `setState` function update an entirely separate state value?

I have an issue where one setState function is updating two separate state values.

I am trying to make a small React sortable array. The program should behave like so:

However, on first click of toggle sorting, it sorts both unsortedData & displayedData, which means I lose the original data order. I understand I could store their order, but I'm wanting to know why these state values appear to be coupled.

Stackblitz working code here.
GIF showing issue here

I cannot find where this is happening. I can't see any object/array referencing going on (I'm spreading for new objects/arrays).

Code here:

const Test = () => {
  const [unsortedData, setUnsortedData] = useState([])
  const [displayedData, setDisplayedData] = useState([])
  const [isSorted, setisSorted] = useState(false)

  const handleSorting = () => setisSorted(!isSorted)

  useEffect(() => {
    if (isSorted === true) setDisplayedData([...unsortedData.sort()]) // sort data
    if (isSorted === false) setDisplayedData([...unsortedData]) // restore original data order
  }, [isSorted, unsortedData])

  useEffect(() => {
    const mockData = [3, 9, 6]
    setUnsortedData([...mockData]) // store original data order in "unsortedData"
    setDisplayedData([...mockData])
  }, [])

  return (
    <div>
      {displayedData.map(item => item)}
      <br />
      <button onClick={() => handleSorting()}>Toggle sorting</button>
    </div>
  )
}

Upvotes: 0

Views: 71

Answers (2)

Nicholas Tower
Nicholas Tower

Reputation: 85211

The .sort function mutates the array, so when you do this:

setDisplayedData([...unsortedData.sort()])

You are mutating unsortedData, then making a copy of it afterwards. Since you mutated the original array, that change can display on the screen when the component rerenders.

So a minimum fix would be to copy first, and sort afterwards:

setDisplayedData([...unsortedData].sort())

Also, why do I need the useEffect? Why can't I just move the contents of the useEffect (that has the if statements in) into the handleSorting function?

Moving it into handleSorting should be possible, but actually i'd like to propose another option: have only two states, the unsorted data, and the boolean of whether to sort it. The displayed data is then a calculated value based on those two.

const [unsortedData, setUnsortedData] = useState([3, 9, 6]) // initialize with mock data
const [isSorted, setisSorted] = useState(false)

const displayedData = useMemo(() => {
  if (isSorted) {
    return [...unsortedData].sort();
  } else {
    return unsortedData
  }
}, [unsortedData, isSorted]);

const handleSorting = () => setisSorted(!isSorted)

// No use useEffect to sort the data
// Also no useEffect for the mock data, since i did that when initializing the state

The benefits of this approach are that

  1. You don't need to do a double render. The original version sets isSorted, renders, then sets displayed data, and renders again
  2. It's impossible to have mismatched states. For example, in between that first and second render, isSorted is true, and yet the displayed data isn't actually sorted yet. But even without the double render case, having multiple states requires you to be vigilant that every time you update unsortedData or isSorted, you also remember to update displayedData. That just happens automatically if it's a calculated value instead of an independent state.

Upvotes: 1

BogdanKostyuk
BogdanKostyuk

Reputation: 91

If you want to display sorted array to user only when the user presses the button, you could use this code statement (orignaly coppied from vue3 todo list)

const filters = {
  none: (data) => [...data],
  sorted: (data) => [...data].sort((a, b) => a - b),
}

const Test = () => {
  const [unsortedData, setUnsortedData] = useState([])
  const [currentFilter, setCurrentFilter] = useState('none')

  useEffect(() => {
    const mockData = [3, 9, 6]
    setUnsortedData([...mockData])
  }, [])

  return (
    <div>
      <ul>
         {/* All magic is going here! */}
         {filters[currentFilter](unsortedData).map(item => <li key={item}>{item}</li>)}
      </ul>
      <br />
      <button
        onClick={() =>
            setCurrentFilter(currentFilter === 'none' ? 'sorted' : 'none')}>
          Toggle sorting
       </button>
    </div>
  )
}

Upvotes: 0

Related Questions