React CSS Transition Inconsistency Through Updates

638 views Asked by At

The below snippet has four boxes. The purpose is that these boxes order will be shuffled and a transition animation occurs as they go to a new location. Each box's key corresponds with a color value from the source array in useState. Each update via the shuffle button, the source array's values are shuffled. Then I map through the array in the return function. I set 2 classNames for each box. One classname corresponds with the index and is for positioning. The other classname corresponds with the source array value and is always in unison with the key for that box.

My issue is that react seems to randomly be deciding what keys to pay attention to and reconcile, and what keys to disregard and just remount those elements. You can see here, some elements properly transition while others just jump to their target location. I'm at a loss as to why this is occuring. Can someone help?

EDIT: I don't believe this is a reconcile issue with respect to unwanted remounting. React is properly respecting the keys and not remounting any. So the issue is with how React handles CSS transition classes added during updates. Some transitions work and others don't. It may just be a limitation of the engine, but if anyone has any further incite please share.

const {useState} = React;

function App() {
  const [state, setState] = useState(['Red', 'Green', 'Blue', 'Black'])

  function handleShuffle() {
    const newState = _.shuffle(state)
    setState(newState)
  }

  return ( 
    <div className="App"> 
      {state.map((sourceValue, index) => {
        return ( 
          <div className={
            'box positionAt' + index + ' sourceValue' + sourceValue
          }
          key={sourceValue} ></div>
        )
      })}

      <button id="shuffle" onClick={handleShuffle}> shuffle < /button> 
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render( <
  App / > ,
  rootElement
);
.App {
  position: relative;
  width: 200px;
  height: 200px;
  background-color: gray;
}

.box {
  width: 25px;
  height: 25px;
  position: absolute;
  transition: transform 1s;
}

.positionAt0 {
  transform: translate(0px, 0px);
}

.positionAt1 {
  transform: translate(175px, 0px);
}

.positionAt2 {
  transform: translate(0px, 175px);
}

.positionAt3 {
  transform: translate(175px, 175px);
}

.sourceValueGreen {
  background-color: green;
}

.sourceValueBlue {
  background-color: blue;
}

.sourceValueRed {
  background-color: red;
}

.sourceValueBlack {
  background-color: black;
}

#shuffle {
  position: absolute;
  top: 0px;
  left: 75px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"></script>
<div id="root"></div>

1

There are 1 answers

1
Sebastian B. On BEST ANSWER

Took me a while to figure it out, because setting the correct keys for the boxes seemed to be just the right thing.

I verified with the developer tools that the keys work, by inspecting a box and storing it in a variable (b = $0), shuffling, re-inspecting the box with the same key (color) and comparing it with the stored node ($0 === b, was true). So The DOM nodes for each key are stable.

But it's not sufficient for CSS transitions because the way browsers are changing the order of elements in the DOM.

You can see it here in a minimized example for efficiently reordering elements in the DOM (I assume that React does similar things internally when elements have to be reordered):

function reorder() {
    const list = document.querySelector("ul");
    list.appendChild(list.firstElementChild);
}
 <ul>
    <li>List-item #1</li>
    <li>List-item #2</li>
</ul>
<button onclick="reorder()">reorder!</button>

Run the example and set a DOM breakpoint on the resulting <ul> DOM node for "Subtree modification", see screenshot.

enter image description here

If you click on "reorder!", the browser breaks first on the removal of a <li>. If you continue, and immediately after continuing (Firefox: <F8>) the browser breaks again with an insertion of a <li>.

(In my tests, the information Chrome gave about the breaks was a bit misleading, Firefox was better at that)

So the browsers implement reordering technically as "remove and insert", which breaks CSS transitions.

With that knowledge the code can easily be fixed by having fixed order of the boxes in the DOM (The order in DOM doesn't need to be changed, because the position is only set via classes):

(Note: *HTML and CSS unchanged, changes in JavaScript are marked with NEW or CHANGE *)

const {useState} = React;

// NEW: List of boxes for a stable order when rendering to the DOM:
const boxes = ['Red', 'Green', 'Blue', 'Black'];

function App() {
  const [state, setState] = useState(boxes); // CHANGE: reuse boxes here

  function handleShuffle() {
    const newState = _.shuffle(state)
    setState(newState)
  }

  return ( 
    <div className="App"> 
      {/* CHANGE: loop over boxes, not state and lookup position, which is used for the positionAt... class */ 
       boxes.map((sourceValue, index) => {
         const position = state.indexOf(sourceValue);
         return ( 
           <div className={
             'box positionAt' + position + ' sourceValue' + sourceValue
           }
           key={sourceValue} ></div>
         )
      })}

      <button id="shuffle" onClick={handleShuffle}> shuffle < /button> 
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render( <
  App / > ,
  rootElement
);
.App {
  position: relative;
  width: 200px;
  height: 200px;
  background-color: gray;
}

.box {
  width: 25px;
  height: 25px;
  position: absolute;
  transition: transform 1s;
}

.positionAt0 {
  transform: translate(0px, 0px);
}

.positionAt1 {
  transform: translate(175px, 0px);
}

.positionAt2 {
  transform: translate(0px, 175px);
}

.positionAt3 {
  transform: translate(175px, 175px);
}

.sourceValueGreen {
  background-color: green;
}

.sourceValueBlue {
  background-color: blue;
}

.sourceValueRed {
  background-color: red;
}

.sourceValueBlack {
  background-color: black;
}

#shuffle {
  position: absolute;
  top: 0px;
  left: 75px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"></script>
<div id="root"></div>

Note: it now works even if no key is set.