<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.26.0/polyfill.js"></script>
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.7.2/redux.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.6/react-redux.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.10.0/js/md5.js"></script>

<div id="root"></div>
* {
    font-family: sans-serif;
}

.item {
    background-color: #cccccc;
    padding: 10px 15px;
    margin-bottom: 10px;
}

.item--updated {
    background-color: #4CAF50;
}

.item--canceled {
    background-color: #F44336;
}

.item--updated-optimistically {
    background-color: #FFEB3B;
}

.api-status {
    padding: 10px 15px;
    font-weight: bold;
}

.api-status--true {
    background-color: #4CAF50;
}

.api-status--false {
    background-color: #F44336;
}
// Imports
const Provider = ReactRedux.Provider;
const createStore = Redux.createStore;
const applyMiddleware = Redux.applyMiddleware;
const combineReducers = Redux.combineReducers;
const connect = ReactRedux.connect;
const bindActionCreators = Redux.bindActionCreators;
const compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || Redux.compose;

// Utils
const delay = (ms) => new Promise(
    (resolve, reject) => setTimeout(resolve, ms)
)

// Redux Actions
const HASH_UPDATED = 'HASH_UPDATED'
const OPTIMISTIC_HASH_UPDATED = 'OPTIMISTIC_HASH_UPDATED'
const OPTIMISTIC_ROLLBACK_HASH_UPDATED = 'OPTIMISTIC_ROLLBACK_HASH_UPDATED'
const SEND_COMMAND_UPDATE_HASH_REQUEST = 'SEND_COMMAND_UPDATE_HASH_REQUEST'
const SEND_COMMAND_UPDATE_HASH_SUCCESS = 'SEND_COMMAND_UPDATE_HASH_SUCCESS'
const SEND_COMMAND_UPDATE_HASH_FAILURE = 'SEND_COMMAND_UPDATE_HASH_FAILURE'
const API_STATUS_TOGGLED = 'API_STATUS_TOGGLED'

// Redux Action Creators
const updateHash = (aggregateId, hash) => ({
    type: SEND_COMMAND_UPDATE_HASH_REQUEST,
    aggregateId,
    hash
});

const toggleApi = () => ({
    type: API_STATUS_TOGGLED
});

// Redux Reducers
const apiReducer = (state = {
    status: true
}, action) => {
    switch(action.type) {
        case API_STATUS_TOGGLED:
            return {
                status: !state.status
            }
        default:
            return state;
    }
}

const hashesReducer = (state = {}, action) => {
    switch(action.type) {
        case HASH_UPDATED:
            return {
                ...state,
                [action.aggregateId]: {
                    ...state[action.aggregateId],
                    status: 'updated',
                    hash: action.hash
                }
            };
        case OPTIMISTIC_HASH_UPDATED:
            return {
                ...state,
                [action.aggregateId]: {
                    ...state[action.aggregateId],
                    status: 'updated-optimistically',
                    hash: md5(action.hash)
                }
            };
        case OPTIMISTIC_ROLLBACK_HASH_UPDATED:
            return {
                ...state,
                [action.aggregateId]: {
                    ...state[action.aggregateId],
                    status: 'canceled',
                    hash: action.hash
                }
            };
        default:
            return state; 
    }
}

const reducer = combineReducers({
    api: apiReducer,
    hashes: hashesReducer
});

// Mock Server Side API and Event Bus (socket)
const createApi = (store) => ({
    async sendCommandCalculateNextHash(aggregateId, hash){
        // Emulate network delay
        await delay(2000);
        if(store.getState().api.status) {
            // Emulate event bus (socket) delay
            (async () => {
                await delay(2000);
                store.dispatch({
                    type: 'HASH_UPDATED',
                    aggregateId,
                    hash: md5(hash)
                })
            })();
        } else {
            throw new Error();
        }
    }
})

// Redux Optimistic Middleware
const optimisticCalculateNextHashMiddleware = (store) => {
    const tempHashes = {};
    
    const api = createApi(store);
    
    return next => action => {
        switch (action.type) {
            case SEND_COMMAND_UPDATE_HASH_REQUEST: {
                const { aggregateId, hash } = action;
                
                // Save the previous data
                const { hashes } = store.getState()
                const prevHash = hashes[aggregateId].hash;
                tempHashes[aggregateId] = prevHash
               
                // Dispatch an optimistic action
                store.dispatch({
                    type: OPTIMISTIC_HASH_UPDATED,
                    aggregateId,
                    hash
                });
                
                // Send a command
                api.sendCommandCalculateNextHash(aggregateId, hash)
                    .then(
                        () => store.dispatch({
                            type: SEND_COMMAND_UPDATE_HASH_SUCCESS,
                            aggregateId,
                            hash
                        })
                    )
                    .catch(
                        (err) => store.dispatch({
                            type: SEND_COMMAND_UPDATE_HASH_FAILURE,
                            aggregateId,
                            hash
                        })
                    );             
                break;
            }
            case SEND_COMMAND_UPDATE_HASH_FAILURE: {
                const { aggregateId } = action;
                
                const hash = tempHashes[aggregateId];
                
                delete tempHashes[aggregateId];
                
                store.dispatch({
                    type: OPTIMISTIC_ROLLBACK_HASH_UPDATED,
                    aggregateId,
                    hash
                });
                break;
            }
            case HASH_UPDATED: {
                const { aggregateId } = action;
                
                const hash = tempHashes[aggregateId];
                
                delete tempHashes[aggregateId];
                
                store.dispatch({
                    type: OPTIMISTIC_ROLLBACK_HASH_UPDATED,
                    aggregateId,
                    hash
                });              
                break;
            }
        }
        
        next(action);
    }
}

// Redux Initial State
const initialState = {
    hashes: {
        firstAggregateId: {
            status: 'initial',
            hash: 'c9c31c1fb779f579175c67d8e9105cd2'
        },
        secondAggregateId: {
            status: 'initial',
            hash: '9bfe486d81fcb49dccda2fa8446a45d4'
        }
    }
};

// Redux Store
const store = createStore(
    reducer, 
    initialState, 
    compose(
        applyMiddleware(optimisticCalculateNextHashMiddleware)
    )
);

// Components
const Item = ({ hash, status, onUpdate }) => (
    <div className={`item item--${status}`}>
        <div>
            Hash: {hash}
        </div>
        <div>
            Status: {status}
        </div>
        <button onClick={onUpdate}>
            Next Hash
        </button>
    </div> 
);

const App = ({ hashes, api, updateHash, toggleApi }) => (
    <div>
        {Object.keys(hashes).map(
            (aggregateId, index) => {
                const { hash, status } = hashes[aggregateId];
                return (
                    <Item 
                        key={aggregateId}
                        hash={hash}
                        status={status}
                        onUpdate={updateHash.bind(null, aggregateId, hash)}
                    />
                );
            }
        )}
        <button onClick={toggleApi} className={`api-status api-status--${api.status}`}>
            API Status
        </button>
    </div>
);   


// Containers
var AppConnected = connect(
    state => ({
        hashes: state.hashes,
        api: state.api
    }),
    dispatch => bindActionCreators({
        updateHash,
        toggleApi
    }, dispatch)
)(App)

// Render
ReactDOM.render((
      <Provider store = {store}>
         <AppConnected/>
      </Provider>
   ),
   document.getElementById('root')
);
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.