Redux: Creating a stopwatch without side effects in the reducer?

Created on 3 Jan 2016  Â·  16Comments  Â·  Source: reduxjs/redux

I've been trying to create a stopwatch with React/Redux. I have a working example in a JSFiddle here. The approach I took was to have a START_TIMER action which in turn uses setInterval to fire off TICK actions which calculate how much time has passed and add that to the current time.

I'm not sure if this approach is valid because I do a clearInterval in the STOP_TIMER part of my reducer, which I assume is a side effect, and my reducer is no longer a pure function. I am pretty new to redux, so I may just be thinking about doing this the wrong way. Is my current example a bad practice in redux, and is there a better way to achieve what I am trying to do? Thanks a lot.

question

Most helpful comment

Calling clearInterval inside the reducer is certainly a bad practice, as it makes the reducer non-pure.

A common way and straightforward way here would be to clear the interval inside your stop() function before you call dispatch.

I've forked your fiddle and implemented what I'd do: https://jsfiddle.net/uu71ka1e/1/. Basically, I add another subscriber to the react store, and have it cancel or start the time depending on whether the isOn property is true in your state.

All 16 comments

Calling clearInterval inside the reducer is certainly a bad practice, as it makes the reducer non-pure.

A common way and straightforward way here would be to clear the interval inside your stop() function before you call dispatch.

I've forked your fiddle and implemented what I'd do: https://jsfiddle.net/uu71ka1e/1/. Basically, I add another subscriber to the react store, and have it cancel or start the time depending on whether the isOn property is true in your state.

The answer by @winstonewert is correct.
For posterity I'll copy the code sample here:

const { createStore } = Redux;

// Initial state for reducer
const initialState = {
  isOn: false,
  time: 0
};

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

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

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

    default: 
      return state;
  }
}

// Create store using the reducer
const store = createStore(timer);

// React Component to display the timer
class Timer extends React.Component {
  constructor() {
    super();
    this.start = this.start.bind(this);
    this.stop = this.stop.bind(this);
  }

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

  stop() {
    store.dispatch({
      type: 'STOP_TIMER'
    });
  }

  format(time) {
    const pad = (time, length) => {
      while (time.length < length) {
        time = '0' + time;
      }
      return time;
    }

    time = new Date(time);
    let m = pad(time.getMinutes().toString(), 2);
    let s = pad(time.getSeconds().toString(), 2);
    let ms = pad(time.getMilliseconds().toString(), 3);

    return `${m} : ${s} . ${ms}`;
  }

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

// render function that runs everytime an action is dispatched
const render = () => {
  ReactDOM.render(
    <Timer 
      time={store.getState().time}
      isOn={store.getState().isOn}
      interval={store.getState().interval}
    />,
    document.getElementById('app')
  );
}

store.subscribe(render);

var interval = null;
store.subscribe(() => {
    if (store.getState().isOn && interval === null) {
      interval = setInterval(() => {
        store.dispatch({
          type: 'TICK',
          time: Date.now()
        });
      });
    }
    if (!store.getState().isOn && interval !== null) {
      clearInterval(interval);
      interval = null;
    }
});

render();

@winstonewert your example was super helpful to me today, thank you!

Hey guy I really appreciate this example! I am getting a syntax error at ...initialState and at ...state

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

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

Thanks for your help!

@aph-vsn, that is new syntax that may not be supported by your development environment. Are you using babel or another transpiler?

@aph-vsn : Yeah, the Object Spread operator is a not-yet-final piece of syntax. See http://redux.js.org/docs/recipes/UsingObjectSpreadOperator.html for more information on using it. Also, note that the Create-React-App tool includes the plugin needed to use the Object Spread operator, so that works out of the box in projects started using Create-React-App.

@winstonewert
I am using babel.

Just use Object.assign({}, state, { timer: 123 }) instead. For me it's more readable.

Ok I fixed the ...initialState.

I got a different problem now. I am using the same code here.

Uncaught TypeError: Cannot read property 'subscribe' of undefined(…)

@aph-vsn : A couple suggestions:

First, you should use triple backticks to properly format code blocks on Github, like:

```js
// code here
```

Second, the discussion is probably best moved to Stack Overflow at this point - you'll get better help there trying to work out code issues.

ok, if you have an idea, please let me know! Thank you

Third, what do you use for bundling? Is there no one error in your terminal? You have named export store but trying to import default.

No error in my terminal.

I got it. Thank you @TrySound you were right

clicking "Stop" then "Start" again making it start again from 00:00.000, I think the right script for 'START_TIMER' is
case 'START_TIMER':
return {
...state,
isOn: true,
offset: action.offset,
};
NOT
case 'START_TIMER':
return {
..._initialState_,
isOn: true,
offset: action.offset,
};

Was this page helpful?
0 / 5 - 0 ratings