17

I've been trying to make a stopwatch in react and redux. I've been having trouble trouble figuring out how to design such a thing in redux.

The first thing that came to mind was having a START_TIMER action which would set the initial offset value. Right after that, I use setInterval to fire off a TICK action over and over again that calculates how much time has passed by using the offset, adds it to the current time, and then updates the offset.

This approach seems to work, but I'm not sure how I would clear the interval to stop it. Also, it seems like this design is poor and there is probably a better way to do it.

Here is a full JSFiddle that has the START_TIMER functionality working. If you just want to see what my reducer looks like right now, here it is:

const initialState = {
  isOn: false,
  time: 0
};

const timer = (state = initialState, action) => {
  switch (action.type) {
    case 'START_TIMER':
      return {
        ...state,
        isOn: true,
        offset: action.offset
      };

    case 'STOP_TIMER':
      return {
        ...state,
        isOn: false
      };

    case 'TICK':
      return {
        ...state,
        time: state.time + (action.time - state.offset),
        offset: action.time
      };

    default: 
      return state;
  }
}

I would really appreciate any help.

Saad
  • 49,729
  • 21
  • 73
  • 112

4 Answers4

46

I would probably recommend going about this differently: store only the state necessary to calculate the elapsed time in the store, and let components set their own interval for however often they wish to update the display.

This keeps action dispatches to a minimum — only actions to start and stop (and reset) the timer are dispatched. Remember, you're returning a new state object every time you dispatch an action, and each connected component then re-renders (even though they use optimizations to avoid too many re-renders inside the wrapped components). Furthermore, many many action dispatches can make it difficult to debug app state changes, since you have to deal with all the TICKs alongside the other actions.

Here's an example:

// Action Creators

function startTimer(baseTime = 0) {
  return {
    type: "START_TIMER",
    baseTime: baseTime,
    now: new Date().getTime()
  };
}

function stopTimer() {
  return {
    type: "STOP_TIMER",
    now: new Date().getTime()
  };
}

function resetTimer() {
  return {
    type: "RESET_TIMER",
    now: new Date().getTime()
  }
}


// Reducer / Store

const initialState = {
  startedAt: undefined,
  stoppedAt: undefined,
  baseTime: undefined
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case "RESET_TIMER":
      return {
        ...state,
        baseTime: 0,
        startedAt: state.startedAt ? action.now : undefined,
        stoppedAt: state.stoppedAt ? action.now : undefined
      };
    case "START_TIMER":
      return {
        ...state,
        baseTime: action.baseTime,
        startedAt: action.now,
        stoppedAt: undefined
      };
    case "STOP_TIMER":
      return {
        ...state,
        stoppedAt: action.now
      }
    default:
      return state;
  }
}

const store = createStore(reducer);

Notice the action creators and reducer deals only with primitive values, and does not use any sort of interval or a TICK action type. Now a component can easily subscribe to this data and update as often as it wants:

// Helper function that takes store state
// and returns the current elapsed time
function getElapsedTime(baseTime, startedAt, stoppedAt = new Date().getTime()) {
  if (!startedAt) {
    return 0;
  } else {
    return stoppedAt - startedAt + baseTime;
  }
}

class Timer extends React.Component {
  componentDidMount() {
    this.interval = setInterval(this.forceUpdate.bind(this), this.props.updateInterval || 33);
  }

  componentWillUnmount() {
    clearInterval(this.interval);
  }

  render() {
    const { baseTime, startedAt, stoppedAt } = this.props;
    const elapsed = getElapsedTime(baseTime, startedAt, stoppedAt);

    return (
      <div>
        <div>Time: {elapsed}</div>
        <div>
          <button onClick={() => this.props.startTimer(elapsed)}>Start</button>
          <button onClick={() => this.props.stopTimer()}>Stop</button>
          <button onClick={() => this.props.resetTimer()}>Reset</button>
        </div>
      </div>
    );
  }
}

function mapStateToProps(state) {
  const { baseTime, startedAt, stoppedAt } = state;
  return { baseTime, startedAt, stoppedAt };
}

Timer = ReactRedux.connect(mapStateToProps, { startTimer, stopTimer, resetTimer })(Timer);

You could even display multiple timers on the same data with a different update frequency:

class Application extends React.Component {
  render() {
    return (
      <div>
        <Timer updateInterval={33} />
        <Timer updateInterval={1000} />
      </div>
    );
  }
}

You can see a working JSBin with this implementation here: https://jsbin.com/dupeji/12/edit?js,output

Michelle Tilley
  • 157,729
  • 40
  • 374
  • 311
  • I apologize for my late comment, but thanks so much! Reading through all this really helped me get a better understanding of how I should be structuring/designing all this. I have two questions if you don't mind. The first is why does the above code not work if I use `null` instead of `undefined`? Secondly, I'm a bit unsure about the line `clearInterval(this.interval);`. Where was `this.interval` defined? Or did you mean to do `this.interval = setInterval()` above it? Thanks once again it means a lot that you would go out of your way to do this! – Saad Jan 19 '16 at 02:52
  • @meh_programmer I used `undefined` so the default argument in `getElapsedTime` works (passing undefined makes it use the default, but that isn't the case when passing null). You're right about the interval — I'll fix that! :) – Michelle Tilley Jan 19 '16 at 04:33
  • Quick Note: It appears you have non pure function in reducer: new Date(): "It’s very important that the reducer stays pure. Things you should never do inside a reducer..." from the documentation https://github.com/reactjs/redux/blob/master/docs/basics/Reducers.md Best practise I think is having all impurities in the ActionCreators https://github.com/reactjs/redux/issues/1088 – David Karlsson May 11 '16 at 13:05
  • @DavidKarlsson You are exactly right, thank you. I've updated the example and the JSBin – Michelle Tilley May 13 '16 at 08:34
  • 1
    I upvoted this answer a while ago but upon thinking further, I disagree with this approach. While you are creating a new state object every dispatch, components relying on state properties that didn't change will not re-render if you are using mapStateToProps. In addition, components should re-render based on state changes. In your approach, no state changes and we are relying on a forced update on an interval to keep that "state" for us. In addition, you cannot serialize such a state. – PDN Jul 25 '17 at 05:49
  • 1
    This is great stopwatch example. I would slightly change the reducer, so that when you dispatch `stopTimer` several times the timer does not get updated. [see it here](https://jsfiddle.net/ab1n5vf9/) – transGLUKator Sep 13 '17 at 17:34
  • @PDN I agree with you, but want to note that shallow equality checks aren't no-ops, and with 1K updates per second that overhead can potentially add up to something non-negligible, especially if there are many subscribers. Who knows, maybe JIT takes care of that, but still, something to watch out for. – Alec Mev Apr 15 '18 at 20:00
  • "In addition, components should re-render based on state changes. In your approach, no state changes and we are relying on a forced update on an interval to keep that "state" for us." You're right, but it's straightforward enough to do the calculation ahead of time and keep some local state in the component for the display. Local state is not evil, but of course your use depends on the application in question, and your balance of pragmatism and best-practice-purity. If I were building this in an app today, I'd likely do something relatively similar. – Michelle Tilley Apr 17 '18 at 20:56
  • I hate redux fat, isn't there any better solution? – Azghanvi Apr 10 '23 at 02:35
11

If you are going to use this in a bigger app then I would use requestAnimationFrame instead of an setInterval for performance issues. As you are showing milliseconds you would notice this on mobile devices not so much on desktop browsers.

Updated JSFiddle

https://jsfiddle.net/andykenward/9y1jjsuz

andykenward
  • 924
  • 7
  • 17
  • Yeah, this is part of a larger app. And thanks a lot, I always thought `requestAnimationFrame` was for doing stuff with `canvas`, I didn't know I could use it in a situation like this. Upvoted! – Saad Jan 03 '16 at 23:41
5

You want to use the clearInterval function which takes the result of a call to setInterval (a unique identifier) and stops that interval from executing any further.

So rather than declare a setInterval within start(), instead pass it to the reducer so that it can store its ID on the state:

Pass interval to dispatcher as a member of the action object

start() {
  const interval = setInterval(() => {
    store.dispatch({
      type: 'TICK',
      time: Date.now()
    });
  });

  store.dispatch({
    type: 'START_TIMER',
    offset: Date.now(),
    interval
  });
}

Store interval on new state within the START_TIMER action reducer

case 'START_TIMER':
  return {
    ...state,
    isOn: true,
    offset: action.offset,
    interval: action.interval
  };

______

Rendering the component according to interval

Pass in interval as a property of the component:

const render = () => {
  ReactDOM.render(
    <Timer 
      time={store.getState().time}
      isOn={store.getState().isOn}
      interval={store.getState().interval}
    />,
    document.getElementById('app')
  );
}

We can then inspect the state within out component to render it according to whether there is a property interval or not:

render() {
  return (
    <div>
      <h1>Time: {this.format(this.props.time)}</h1>
      <button onClick={this.props.interval ? this.stop : this.start}>
        { this.props.interval ? 'Stop' : 'Start' }
      </button>
    </div>
  );
}

______

Stopping the timer

To stop the timer we clear the interval using clearInterval and simply apply the initialState again:

case 'STOP_TIMER':
  clearInterval(state.interval);
  return {
    ...initialState
  };

______

Updated JSFiddle

https://jsfiddle.net/8z16xwd2/2/

sdgluck
  • 24,894
  • 8
  • 75
  • 90
  • Thanks a lot for the answer and explanations! This is pretty much what I was trying to do. I just have one other question – would you say it is bad practice to have an action which fires off another action with `setInterval`? – Saad Jan 03 '16 at 13:52
  • @meh_programmer Possibly, yes. [This discussion on GitHub](https://github.com/rackt/redux/issues/184#issuecomment-115987375) seems to suggest that placing the interval logic inside a separate 'business logic' object that subscribes to the store would be more sensible. – sdgluck Jan 03 '16 at 13:59
1

Similar to andykenward's answer, I would use requestAnimationFrame to improve performance as the frame rate of most devices is only about 60 frames per second. However, I would put as little in Redux as possible. If you just need the interval to dispatch events, you can do that all at the component level instead of in Redux. See Dan Abramov's comment in this answer.

Below is an example of a countdown Timer component that both shows a countdown clock and does something when it has expired. Inside the start, tick, or stop you can dispatch the events that you need to fire in Redux. I only mount this component when the timer should start.

class Timer extends Component {
  constructor(props) {
    super(props)
    // here, getTimeRemaining is a helper function that returns an 
    // object with { total, seconds, minutes, hours, days }
    this.state = { timeLeft: getTimeRemaining(props.expiresAt) }
  }

  // Wait until the component has mounted to start the animation frame
  componentDidMount() {
    this.start()
  }

  // Clean up by cancelling any animation frame previously scheduled
  componentWillUnmount() {
    this.stop()
  }

  start = () => {
    this.frameId = requestAnimationFrame(this.tick)
  }

  tick = () => {
    const timeLeft = getTimeRemaining(this.props.expiresAt)
    if (timeLeft.total <= 0) {
      this.stop()
      // dispatch any other actions to do on expiration
    } else {
      // dispatch anything that might need to be done on every tick
      this.setState(
        { timeLeft },
        () => this.frameId = requestAnimationFrame(this.tick)
      )
    }
  }

  stop = () => {
    cancelAnimationFrame(this.frameId)
  }

  render() {...}
}
Sia
  • 8,894
  • 5
  • 31
  • 50