Skip to content

Instantly share code, notes, and snippets.

@sompylasar
Created December 8, 2017 23:43
Show Gist options
  • Save sompylasar/d6e3563ecc26425a44e33a317da015f1 to your computer and use it in GitHub Desktop.
Save sompylasar/d6e3563ecc26425a44e33a317da015f1 to your computer and use it in GitHub Desktop.
Pure clock state with React, Redux, Redux-Saga. Do not use `Date.now()` or `new Date()` or `moment()` in `render()`, reading the current clock state is not pure (returns different values on each call). https://twitter.com/acdlite/status/939260579247562752
export const ACTION_CLOCK_SUBSCRIBE = 'clock/ACTION_CLOCK_SUBSCRIBE';
export const ACTION_CLOCK_UNSUBSCRIBE = 'clock/ACTION_CLOCK_UNSUBSCRIBE';
export const ACTION_CLOCK_UPDATED = 'clock/ACTION_CLOCK_UPDATED';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
extractClockTimestamp,
} from './reduxClockReducer';
import {
ACTION_CLOCK_SUBSCRIBE,
ACTION_CLOCK_UNSUBSCRIBE,
} from './reduxClockActions';
import makeAction from './makeAction';
/**
* A component that provides the clock updates in real time.
*/
class RshClockProvider extends Component {
componentDidMount() {
const {
clockUpdateIntervalInMs,
dispatch,
} = this.props;
this._subscriptionId = Math.random();
dispatch(makeAction(ACTION_CLOCK_SUBSCRIBE, {
subscriptionId: this._subscriptionId,
interval: clockUpdateIntervalInMs,
}));
}
componentWillUnmount() {
const {
dispatch,
} = this.props;
dispatch(makeAction(ACTION_CLOCK_UNSUBSCRIBE, {
subscriptionId: this._subscriptionId,
}));
}
render() {
const {
children,
clockTimestamp,
} = this.props;
const renderedChildren = children(clockTimestamp, this.props);
return ( renderedChildren && React.Children.only(renderedChildren) );
}
}
RshClockProvider.propTypes = {
children: PropTypes.func.isRequired,
clockTimestamp: PropTypes.number.isRequired,
clockUpdateIntervalInMs: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
RshClockProvider.defaultProps = {
// NOTE(@sompylasar): By default provide a 15-second interval which is enough for one-minute-precision clock.
// NOTE(@sompylasar): A 500-millisecond interval should be enough for one-second-precision clock.
clockUpdateIntervalInMs: 15000,
};
const RshClockProviderConnected = connect(
(globalState) => ({
clockTimestamp: extractClockTimestamp(globalState),
})
)(RshClockProvider);
export default RshClockProviderConnected;
import {
ACTION_CLOCK_SUBSCRIBE,
ACTION_CLOCK_UNSUBSCRIBE,
ACTION_CLOCK_UPDATED,
} from './reduxClockActions';
// store key
export const STORE_KEY = 'clock';
// initial state
const initialState = {
subscriptionCount: 0,
clockTimestamp: Date.now(),
};
// state selectors
export function extractState(globalState) {
return (globalState[STORE_KEY] || initialState);
}
export function extractClockTimestamp(globalState) {
return extractState(globalState).clockTimestamp;
}
// reducer
export function reducer(state = initialState, action = {}) {
switch (action.type) {
case ACTION_CLOCK_SUBSCRIBE:
return {
...state,
subscriptionCount: state.subscriptionCount + 1,
};
case ACTION_CLOCK_UNSUBSCRIBE:
return {
...state,
subscriptionCount: state.subscriptionCount - 1,
};
case ACTION_CLOCK_UPDATED:
return {
...state,
clockTimestamp: action.payload.clockTimestamp,
};
default:
return state;
}
}
import {
put,
take,
fork,
call,
} from 'redux-saga/effects';
import when from 'when';
import lodashReduce from 'lodash/reduce';
import makeAction from './makeAction';
import {
ACTION_CLOCK_SUBSCRIBE,
ACTION_CLOCK_UNSUBSCRIBE,
ACTION_CLOCK_UPDATED,
} from './reduxClockActions';
const _subscriptions = {};
let _intervalCurr = 0;
let _updaterRunning = false;
function makePromiseDelay(delay) {
return when().delay(delay);
}
function* updateClock() {
try {
_updaterRunning = true;
while (true) { // eslint-disable-line no-constant-condition
yield put(makeAction(ACTION_CLOCK_UPDATED, {
clockTimestamp: Date.now(),
}));
if ( _intervalCurr <= 0 ) {
return;
}
yield call(makePromiseDelay, _intervalCurr);
if ( _intervalCurr <= 0 ) {
return;
}
}
}
finally {
_updaterRunning = false;
}
}
function findMinInterval() {
return lodashReduce(_subscriptions, (accu, interval) => {
return (accu === 0 || interval < accu ? interval : accu);
}, 0);
}
function* updateInterval() {
// NOTE(@sompylasar): Schedule the clock updates at the highest requested frequency.
const intervalNext = findMinInterval();
const intervalCurr = _intervalCurr;
_intervalCurr = intervalNext;
// NOTE(@sompylasar): Update the clock once if the next highest frequency is higher than the current one.
if ( intervalCurr > 0 && intervalNext > 0 && intervalNext < intervalCurr ) {
yield put(makeAction(ACTION_CLOCK_UPDATED, {
clockTimestamp: Date.now(),
}));
}
// NOTE(@sompylasar): Launch the updater if none is running.
if ( !_updaterRunning ) {
yield fork(updateClock);
}
}
function* watchClockSubscribe() {
while (true) { // eslint-disable-line no-constant-condition
const { payload: { subscriptionId, interval } } = yield take(ACTION_CLOCK_SUBSCRIBE);
if ( interval > 0 ) {
_subscriptions[subscriptionId] = interval;
yield* updateInterval();
}
}
}
function* watchClockUnsubscribe() {
while (true) { // eslint-disable-line no-constant-condition
const { payload: { subscriptionId } } = yield take(ACTION_CLOCK_UNSUBSCRIBE);
delete _subscriptions[subscriptionId];
yield* updateInterval();
}
}
export default function* clockSaga(...args) {
yield put(makeAction(ACTION_CLOCK_UPDATED, {
clockTimestamp: Date.now(),
}));
yield [
fork(watchClockSubscribe, ...args),
fork(watchClockUnsubscribe, ...args),
];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment