How do I clear a timer using in my React component

2019-08-28 23:58发布

问题:

I'm having trouble figuring out why my counter won't reset when I attempt to reset it from a control. I suspect that that I'm making some kind of novice (common and not embarrassing) mistake in how I manipulate state from within my controls.

For example, if I clock "Faster" several times and then click "Normal" counting continues at an accelerated pace: apparently the faster timer has not been cleared by the invocation of startTimer. Only by subsequently clicking "Reset", or "Stop" followed by "Start" does the faster timer appear to clear. But I'm mystified by why this should be the case: all paths use clearInterval in the same way.

I suspect that I'm not grasping something general about how state is manipulated in a component; or perhaps how to correctly access a timer from component state.

Why can't I get my timer to clear as expected?

WobblyCounter.tsx:

import React, { useState } from 'react'
import { View, Button, Text } from 'native-base'
import { useDispatch, useSelector } from 'react-redux'

const WobblyCounter = () => {

    const [ timerID, setTimerID ] = useState(0)
    const [ isRunning, updateIsRunning ] = useState(false)
    const [ interval, updateInterval ] = useState(1000)

    const count = useSelector((state) => state.count)
    const dispatch = useDispatch()

    const startTimer = (): void => {
        clearInterval(timerID)
        setTimerID(setInterval(() => { dispatch( {type: "INCREMENT", step: 1} ) }, interval))
        updateIsRunning(true)
    }

    const stopTimer = (): void => {
        clearInterval(timerID)
        updateIsRunning(false)
    }

    return (
        <View style={ {paddingTop:50} }>
            <Button
                onPress={ (): void => { dispatch( {type: "RESET"} ); startTimer() } }>
                <Text>Reset</Text>
            </Button>
            <View style={ {flexDirection: "row"} }>
                <Button small bordered dark disabled={ interval <= 250 }
                    onPress={ (): void => { updateInterval(Math.max(interval - 250, 250)); startTimer() } }>
                    <Text>Faster</Text>
                </Button>
                <Button small bordered dark disabled={ interval == 1000 }
                    onPress={ (): void => { updateInterval(1000); startTimer() } }>
                    <Text>Normal</Text>
                </Button>
                <Button small bordered dark
                    onPress={ (): void => { updateInterval(interval + 250); startTimer() } }>
                    <Text>Slower</Text>
                </Button>
            </View>
            <Button small style={ Object.assign( {}, {backgroundColor: isRunning ? "red" : "green"} ) }
                onPress={ (): void => { isRunning ? stopTimer() : startTimer() } }>
                <Text>{isRunning ? "Stop" : "Start"}</Text>
            </Button>
            <Text>
                Debug{"\n"}count = {count}{"\n"}interval = {interval}{"\n"}timerID = {timerID}
            </Text>
        </View>
    )

}

export default WobblyCounter

回答1:

The main problem here is that the closure startTimer is using old state value :

  1. on first render timerID=0 and interval=1000, startTimer is created with these values.
  2. when you click on "Faster", updateInterval is called and the interval state is changed to 750 but the component isn't rendered yet, and so startTimer is called with the old value interval=1000.
  3. The component re-render timerID=1 and interval=750, startTimer is re-created with these values.
  4. when you click on "Normal", updateInterval is called and the interval state is changed to 1000 but the component isn't rendered yet, startTimer is called with the old value interval=750. That is why the counter is still running fast.

One way to fix this problem is to use a custom hook useInterval proposed here by Dan Abramov, and only update the relevant state (interval, isRunning) when the buttons are clicked.

  useInterval(
     () => {
         dispatch({ type: "INCREMENT", step: 1 });
     },
     isRunning ? interval : null
  );

You can find the completed code here (I removed react-native)



回答2:

You reset the timer via cleanup callback of useEffect with an empty dep array.

Which means this side effect will run once on the component unmount:

useEffect(() => {
  // startTimer will run once on component mount
  startTimer();

  // The cleanup callback will run once on component unmount
  return stopTimer;
}, []);

But, in your case, you never unmount the component (because you dispatch actions on button clicks, that mean you always at update cycle), try put a breakpoint at stopTimer.