Home > Mobile >  Same logic but different behaviour in 'class' and in 'functional component'
Same logic but different behaviour in 'class' and in 'functional component'

Time:10-10

Attempted to translate an example code from class to functional component and faced the problem.

the target file is in components/Wheel/index.js

Key function that causes problem

  const selectItem = () => {
   
   if (selectedItem === null) {
   
     const selectedItem = Math.floor(Math.random() * items.length);
     console.log(selectedItem);
     setSelectedItem(selectedItem);
   } else {
     
     setSelectedItem(null);
    let t= setTimeout(() => {
   
       selectItem()
     }, 500);
   clearTimeout(t);
   }
 };

First time is normal, from second time onward, 2 clicks are needed for the wheel to spin. I had to add clearTimeout() or infinite loop is resulted, but the same does not happen in the original.

Original working example in class

Codesandbox Demo

My version in functional component.

MyVersion

Thank you.

CodePudding user response:

What an excellent nuance of hooks you've discovered. When you call selectItem in the timeout, the value of selectedItem that is captured in lexical scope is the last value (not null).

There's two answers, a simple answer and a better working answer.

The simple answer is you can accomplish it be simply separating the functions: https://codesandbox.io/s/spinning-wheel-game-forked-cecpi

It looks like this:


  const doSelect = () => {
    setSelectedItem(Math.floor(Math.random() * items.length));
  };

  const selectItem = () => {
    if (selectedItem === null) {
      doSelect();
    } else {
      setSelectedItem(null);
      setTimeout(doSelect, 500);
    }
  };

Now, read on if you dare.

The complicated answer fixes the solution for the problem if items.length may change in between the time a timer is set up and it is fired:

https://codesandbox.io/s/spinning-wheel-game-forked-wmeku

Rerendering (i.e. setting state) in a timeout causes complexity - if the component re-rendered in between the timeout, then your callback could've captured "stale" props/state. So there's a lot going on here. I'll try and describe it as best I can:

  const [selectedItem, setSelectedItem] = useState(null);

  // we're going to use a ref to store our timer
  const timer = useRef();

  const { items } = props;

  // this is just the callback that performs a random select
  // you can see it is dependent on items.length from props
  const doSelect = useCallback(() => {
    setSelectedItem(Math.floor(Math.random() * items.length));
  }, [items.length]);


  // this is the callback to setup a timeout that we do
  // after the user has clicked a "second" time.
  // it is dependent on doSelect
  const doTimeout = useCallback(() => {
    timer.current = setTimeout(() => {
      doSelect();
      timer.current = null;
    }, 500);
  }, [doSelect]);

  // Here's the tricky thing: if items.length changes in between 
  // the time we rerender and our timer fires, then the timer callback will have 
  // captured a stale value for items.length.
  // The way we fix this is by using this effect.
  // If items.length changes and there is a timer in progress we need to:
  // 1. clear it
  // 2. run it again
  //
  // In a perfect world we'd be capturing the amount of time remaining in the 
  // timer and fire it exactly (which requires another ref)
  // feel free to try and implement that!
  useEffect(() => {
    if (!timer.current) return;
    clearTimeout(timer.current);
    doTimeout();
    // it's safe to ignore this warning because
    // we know exactly what the dependencies are here
  }, [items.length, doTimeout]);

  const selectItem = () => {
    if (selectedItem === null) {
      doSelect();
    } else {
      setSelectedItem(null);
      doTimeout();
    }
  };
  • Related