My latest bugfix: or, how I went spelunking in someone else‘s code

As a full-stack developer, few things are as satisfying as successfully tracking down and squashing a tricky bug. Even better is when that bug hunt takes you on a deep dive into an unfamiliar codebase, giving you a chance to learn new things and sharpen your debugging skills.

I recently had just such an opportunity when a colleague asked for help with a React Native stopwatch component he was working on. The stopwatch was supposed to be a simple feature in a fitness tracking app, but the darn thing just wouldn‘t work right. Seconds would skip, the start/stop button became unresponsive, and the lap times made no sense. Time for some bug spelunking!

Examining the crime scene

First step was to carefully read through the problematic stopwatch code to get the lay of the land. The component used the common pattern of managing stopwatch state with boolean flags like isRunning and startTime and endTime timestamps.

A simplified version of the original buggy render method looked something like this:

render() {
  const { isRunning, startTime, endTime } = this.state;
  const secondsElapsed = (endTime - startTime) / 1000;

  return (
    <View>
      <Text>{secondsElapsed.toFixed(2)}s</Text>
      <Button onPress={this.handleToggle}>
        {isRunning ? ‘Stop‘ : ‘Start‘}
      </Button>
      <Button onPress={this.handleReset}>Reset</Button>
    </View>
  );
}

And the handler methods that updated the stopwatch state:

handleToggle = () => {
  this.setState(prevState => {
    if (prevState.isRunning) {
      return { isRunning: false, endTime: Date.now() };
    } else {    
      return { isRunning: true, startTime: Date.now() };
    }
  });
}

handleReset = () => {
  this.setState({ startTime: 0, endTime: 0 });
}

Can you spot any red flags? There are a few issues, but the most glaring is that the secondsElapsed calculation is using potentially stale startTime and endTime values. React state updates are asynchronous, so there‘s no guarantee that the timestamps will be up-to-date when the render method runs. This explained why the displayed time was unreliable.

Attempting a fix

Okay, so we need to ensure secondsElapsed always uses the latest timestamps. One way is to calculate the elapsed time based on the current time every render:

render() {
  const { isRunning, startTime } = this.state;
  const now = Date.now();
  const secondsElapsed = isRunning ? 
    (now - startTime) / 1000 : 
    (this.state.endTime - startTime) / 1000;

  // JSX output
}

I made this update, but the stopwatch still misbehaved. The timer started off okay, but after hitting stop and start a few times, the seconds would suddenly jump around erratically. What was going on?

Digging deeper

After some head-scratching, I realized the issue was with how the start time was being set. When resuming the stopwatch, the code was resetting startTime to the current timestamp:

return { isRunning: true, startTime: Date.now() };

But this loses the original start time, making the elapsed time calculation incorrect if there were previous start/stop cycles. To fix, we need to preserve the original startTime and track the cumulative elapsed duration across start/stop actions.

I refactored the state to store elapsedTime and updated the handlers:

handleToggle = () => {
  this.setState(prevState => {
    if (prevState.isRunning) {
      // Stopping - freeze elapsed time 
      return { 
        isRunning: false,
        elapsedTime: Date.now() - prevState.startTime  
      };
    } else {    
      // Resuming - continue previous elapsed time
      return { 
        isRunning: true, 
        startTime: Date.now() - prevState.elapsedTime
      };
    }
  });
}

handleReset = () => {
  this.setState({ elapsedTime: 0 });
}

And updated the render method:

render() {
  const { isRunning, elapsedTime } = this.state;
  const now = Date.now();
  const secondsElapsed = isRunning ? 
    ((now - this.state.startTime) + elapsedTime) / 1000 : 
    elapsedTime / 1000;

  // JSX output
}

With this approach, the stopwatch properly resumes from where it left off after stopping and restarting. We‘re getting close!

The final bugs

I tested the rewritten stopwatch thoroughly and noticed a couple last glitches:

  1. Occasionally the displayed time would noticeably lag behind or stutter
  2. Mashing the start/stop button really fast would sometimes freeze the timer

The first problem is due to relying on state changes to update the timer. Even with the improved elapsed time tracking, the output can still lag behind since state updates are batched asynchronously by React. We need a more reliable and snappy way to regularly refresh the display.

The solution is to force synchronous updates on a fixed interval, bypassing state. I added a separate useInterval hook to run a callback every 100ms:

function useInterval(callback, delay) {
  const latestCallback = useRef(null);

  useEffect(() => {
    latestCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay !== null) {
      const interval = setInterval(() => latestCallback.current(), delay);
      return () => clearInterval(interval);
    }
  }, [delay]);
}

And refactored the stopwatch to use it:

const StopWatch = () => {
  const [isRunning, setIsRunning] = useState(false);
  const [elapsedTime, setElapsedTime] = useState(0);
  const [startTime, setStartTime] = useState(0);

  const toggleTimer = () => {
    if (isRunning) {
      // Stopping 
      setElapsedTime(Date.now() - startTime);
      setIsRunning(false);
    } else {
      // Resuming
      setStartTime(Date.now() - elapsedTime);
      setIsRunning(true);
    }
  };

  const resetTimer = () => {
    setStartTime(0);
    setElapsedTime(0);
    setIsRunning(false);
  };

  // Tick every 100ms and force update
  useInterval(() => {
    if (isRunning) {
      setElapsedTime(Date.now() - startTime);
    }
  }, isRunning ? 100 : null);  

  const secondsString = (elapsedTime / 1000).toFixed(2);

  return (
    <View>
      <Text>{secondsString}s</Text>
      <Button onPress={toggleTimer}>
        {isRunning ? ‘Stop‘ : ‘Start‘}
      </Button>      
      <Button onPress={resetTimer}>Reset</Button>
    </View>
  );
}

Now the timer updates every 100ms whenever the stopwatch is running. It‘s much smoother and stays perfectly in sync. On to the final bug!

The start/stop button freezing was happening because the state updates couldn‘t keep up with the rapid clicks. Each click triggered a state change, but React would batch them asynchronously, leaving the button in a weird indeterminate state.

To solve this, I wrapped the button‘s onPress handler in a useCallback hook with an isRunning dependency. This memoizes the handler and ensures the button only responds to the latest isRunning value, effectively debouncing the rapid clicks:

const toggleTimer = useCallback(() => {
  if (isRunning) {
    setElapsedTime(Date.now() - startTime);
    setIsRunning(false);
  } else {
    setStartTime(Date.now() - elapsedTime);
    setIsRunning(true);
  }
}, [isRunning]);

And with that, the stopwatch was finally rock-solid! It accurately tracks time, smoothly updates the display, handles any sequence of starts/stops, and resets to zero on demand.

Lessons learned

Fixing these stopwatch bugs was a great reminder of a few key things:

  1. Carefully manage timestamps and durations. Tracking elapsed time across start/stop actions requires extra thought to handle all cases.

  2. Be aware of React state update timing. Asynchronous state updates can cause surprises, like stuttery displays. Options are to use refs for synchronous changes or force updates on an interval.

  3. Use memoization for rapid actions. Wrapping handlers in useCallback prevents unexpected behavior from rapid state changes.

  4. Verify all edge cases. Seemingly innocuous bugs can lurk in places like mashing buttons. Thoroughly test all combinations!

By applying these techniques and really thinking through all the possible states, you can implement a robust stopwatch in React Native. It takes some effort, but getting it pixel-perfect is very satisfying! The same concepts apply to any time-based UI feature.

I hope this deep dive into debugging a real stopwatch was insightful. The next time you hit a tricky bug, embrace it as an opportunity to hone your skills and learn the codebase inside-out. Think methodically, test thoroughly, and code defensively. Happy bug hunting!

Similar Posts