React styles update only after wheel event fulfilled

Viewed 66

What I want to achieve is smoothly scaled div container while scrolling (using mouse wheel to be strict) so user can zoom in and out.

However, my styles are "applied" by the browser only either when I scroll really slow or scroll normally and then wait about 0.2 seconds (after that time the changes are "bunched up"). I would like for the changes to be visible even during "fast" scrolling, not at the end.

The element with listener:

<div onWheel={(event) => {
         console.log("wheeling"); // this console log fires frequently, 
                                  // and I want to update styles at the same rate
         changeZoom(event);
     }}
>
    <div ref={scaledItem}> // content div that will be scaled according to event.deltaY
        ... // contents
    </div>
</div>

My React code:

const changeZoom = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
    if (!scaledItem.current) return;
    const newZoom = parseFloat(scaledItem.current.style.scale) + event.deltaY * 0.001;
    console.log(newZoom); // logs as frequently as "wheeling" above
    setCurrentZoom(newZoom);
}, []);


useEffect(() => {
    if (!scaledItem.current) return;
    scaledItem.current.style.scale = currentZoom.toString();
}, [currentZoom]);

useEffect(() => {        // this is just for reproduction, needs to set initial scale to 1
    if (!scaledItem.current) return;
    scaledItem.current.style.scale = "1";
}, [])

What I have tried first was to omit all the React states, and edit scaledItem.current.style.scale directly from useCallback, but the changes took place in a bunch, after the wheeling events stopped coming. Then I moved zoom amount to currentZoom useState hook, but rerenders don't help either.

Edit: I have also tried adding EventListener inside useEffect directly to the DOM Node:

useEffect(() => {
    if (!scaledItemWrapper.current) return; // ref for wrapper of my scaled content
    const container = scaledItemWrapper.current;
    container.addEventListener("wheel", changeZoom);
    return () => {
        container.removeEventListener("wheel", changeZoom);
    };
}, [changeZoom]);
2 Answers

Instead of setting up multiple states and observing can you try using a single state below is a working example. Try this if this works

https://codesandbox.io/s/wonderful-cerf-69doe?file=/src/App.js:0-727

export default () => {
  const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });

  const changeZoom = (e) => {
    e.preventDefault();
    const delta = e.deltaY * -0.01;
    const newScale = pos.scale + delta;

    const ratio = 1 - newScale / pos.scale;

    setPos({
      scale: newScale,
      x: pos.x + (e.clientX - pos.x) * ratio,
      y: pos.y + (e.clientY - pos.y) * ratio
    });
  };

  return (
    <div onWheelCapture={changeZoom}>
      <img
        src="https://source.unsplash.com/random/300x300?sky"
        style={{
          transformOrigin: "0 0",
          transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`
        }}
      />
    </div>
  );
};

Use a CSS transition

What I want to achieve is smoothly scaled div container while scrolling

The question's JavaScript

I didn't have to make changes to the posted JavaScript in my answer's code snippets, besides replacing scale with transform: scale() because it currently has incomplete browser support. Perhaps this can be written better but it does the job here, and is not the cause of the choppy behavior you observe.

Creating fluid motion from choppy input

Scroll events are by nature "bunched" because they arrive as the wheel is being turned "a notch". While that's not as true for all scrollable devices, it is for most people's mouse, so we have to deal with it for the foreseeable future. The browser also does additional bunching, in case of fast motion, but even without that the problem is already there.

So it's best to write code in a way that choppy input still results in a fluid motion, regardless of the step size. Once you have that, it automatically accounts for additional bunching by the browser.

You can add a CSS transition on the transform property to smooth out the scaling movement. It seems to work well with a value of 0.2 seconds, which I assume makes sense as it spreads the motion over the 0.2 seconds the browser is bunching up the changes in.

transition: transform 0.2s ease-out;

Performance implications

As a bonus, your app can keep rendering just 5 times a second.

Conversely, a solution that causes React to capture the maximum amount of fine grained scroll events will likely cause performance issues. A CSS transform is a lot cheaper then achieving the same effect through repeated renders.

Demonstration

You can observe the difference in the following 2 snippets.

It only runs properly if you open it full page. Otherwise it works but it will scroll the whole page too. I didn't want to make the code overly complex just to prevent that on SO.

Without transition (choppy)

const {useCallback, useEffect, useState, useRef} = React;

const minZoom = .01;

function App() {
  const [currentZoom, setCurrentZoom] = useState("1");
  const scaledItem = useRef();

  const changeZoom = useCallback((event) => {
    if (!scaledItem.current) return;
    const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
    const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
    console.log(newZoom); // logs as frequently as "wheeling" above
    setCurrentZoom(newZoom);
  }, []);


  useEffect(() => {
      if (!scaledItem.current) return;
      scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
  }, [currentZoom]);

  useEffect(() => {        // this is just for reproduction, needs to set initial scale to 1
      if (!scaledItem.current) return;
      scaledItem.current.style.transform = "scale(1)";
  }, [])

  
  return <div onWheel={(event) => {
         console.log("wheeling"); 
         changeZoom(event);
     }}
  >
    <div class="scaled" ref={scaledItem}>
      <p>Scale me up and down! (Use "Full page" link of snippet)</p>
    </div>
  </div>
}

ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
  border: 2px solid lightgreen;
  transform: scale(1);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id="root"></div>

<div id="root2"></div>

With transition (smooth)

const {useCallback, useEffect, useState, useRef} = React;

const minZoom = .01;

function App() {
  const [currentZoom, setCurrentZoom] = useState("1");
  const scaledItem = useRef();

  const changeZoom = useCallback((event) => {
    if (!scaledItem.current) return;
    const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
    const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
    console.log(newZoom); // logs as frequently as "wheeling" above
    setCurrentZoom(newZoom);
  }, []);


  useEffect(() => {
      if (!scaledItem.current) return;
      scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
  }, [currentZoom]);

  useEffect(() => {        // this is just for reproduction, needs to set initial scale to 1
      if (!scaledItem.current) return;
      scaledItem.current.style.transform = "scale(1)";
  }, [])

  
  return <div onWheel={(event) => {
         console.log("wheeling"); 
         changeZoom(event);
     }}
  >
    <div class="scaled" ref={scaledItem}>
      <p>Scale me up and down! (Use "Full page" link of snippet)</p>
    </div>
  </div>
}

ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
  border: 2px solid lightgreen;
  transform: scale(1);
  transition: transform .2s ease-out;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id="root"></div>

Related