├── README.md ├── package.json ├── public └── index.html ├── redux.png └── src ├── containers └── sample-module │ ├── View.jsx │ ├── components │ └── SubComponents.jsx │ ├── helpers │ ├── index.js │ └── staticProps.js │ └── index.js ├── helpers └── index.js ├── index.js ├── redux ├── actions │ ├── api │ │ ├── epics.js │ │ ├── helpers.js │ │ ├── index.js │ │ └── types.js │ ├── error │ │ ├── index.js │ │ └── types.js │ ├── index.js │ ├── sample-module │ │ ├── index.js │ │ └── types.js │ └── ui │ │ ├── index.js │ │ └── types.js ├── middleware │ ├── error │ │ └── index.js │ ├── multi-dispatch │ │ └── index.js │ └── sample-module │ │ └── index.js ├── reducers │ ├── index.js │ ├── sample-module │ │ ├── index.js │ │ └── model.js │ └── ui │ │ ├── index.js │ │ └── model.js └── store.js └── styles.css /README.md: -------------------------------------------------------------------------------- 1 | # advanced-redux-pattern 2 | 3 | Advanced redux pattern for handling multiple sub dispatch, reusable middleware and scalable redux for large application. 4 | 5 | Live Demo 6 | 7 | [![Edit advanced-redux-pattern](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/kmwo4vr4x3) 8 | 9 | Inspiration [Advanced Redux Patterns - Nir Kaufman @ ReactNYC](https://youtu.be/JUuic7mEs-s) 10 | 11 | ![alt text](./redux.png "Advanced Redux Pattern") 12 | 13 | ## Features: 14 | - State management using [redux](https://redux.js.org/) 15 | - Reusable Ajax Request as dispatch using [rxjs](https://github.com/ReactiveX/rxjs) & [redux-observables](https://redux-observable.js.org/) for side effects 16 | - Cancellable Request, in `ComponentWillUnmount` or manual cancel request 17 | - Single Source of Data Flow as Middleware for sub dispatch and multiple dispatch 18 | - Action Creators using [redux-actions](https://github.com/redux-utilities/redux-actions) 19 | - UI Library using [ant-design](https://ant.design/) 20 | - Readable Action Types in redux-dev-tools 21 | - Implement Folder Structure for Containers & Components 22 | - Api Error handling in single pipeline 23 | - Tutorial @medium [article](https://medium.com/@nenjotsu/scalable-redux-advanced-pattern-with-reactjs-c56ea97245f) 24 | 25 | ## Why do you need a sub dipatch 26 | For instance you will need to maintain the flow of dispatch in separate folder, to adhere the separation of concerns and debugging purposes, the component will remain clean and not cluttered of multiple or series of this.props.getEpicOne(), this.props.getEpicTwo(), here are reasons why: 27 | - clean and not cluttered dispatch inside a component 28 | - easy to debug the side effects in multiple requests 29 | - manageable flow dispatch to handle multiple dispatch and ajax request 30 | - dispatch a regular actions alongside with the actions with side effects 31 | - debuggable flow where/when you start the spinner (loading) and where to stop it 32 | - reusable dispatch for ajax request, ui, or master data 33 | - scalable redux and open for extension once you want to use other middleware like `redux-saga`, co-existing or even while you are implementing a new technology for a specific module/usecase. 34 | 35 | ## TODOs 36 | - Implement [reselect](https://github.com/reduxjs/reselect) for memoizing redux state 37 | 38 | ## For more question follow me 39 | - twitter [@nenjotsu](https://twitter.com/nenjotsu) 40 | - github [@nenjotsu](https://github.com/nenjotsu) 41 | - medium [@nenjotsu](https://medium.com/@nenjotsu) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "advanced-redux-pattern", 3 | "version": "1.0.0", 4 | "description": "Advanced redux pattern for handling multiple sub dispatch, reusable middleware and scalable redux for large application.", 5 | "keywords": [ 6 | "redux", 7 | "redux-observables", 8 | "advanced-pattern", 9 | "middleware" 10 | ], 11 | "main": "src/index.js", 12 | "dependencies": { 13 | "antd": "3.10.1", 14 | "query-string": "6.2.0", 15 | "react": "16.5.2", 16 | "react-dom": "16.5.2", 17 | "react-redux": "5.0.7", 18 | "react-scripts": "2.0.3", 19 | "redux": "3.6.0", 20 | "redux-actions": "2.6.1", 21 | "redux-observable": "0.17.0", 22 | "rxjs": "5.5.6" 23 | }, 24 | "devDependencies": {}, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test --env=jsdom", 29 | "eject": "react-scripts eject" 30 | }, 31 | "browserslist": [ 32 | ">0.2%", 33 | "not dead", 34 | "not ie <= 11", 35 | "not op_mini all" 36 | ] 37 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /redux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nenjotsu/advanced-redux-pattern/dcdb78e9dabdf0b3f760ea6db756aaf2d3f4b5c7/redux.png -------------------------------------------------------------------------------- /src/containers/sample-module/View.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Button from "antd/lib/button"; 3 | import List from "antd/lib/list"; 4 | import Spin from "antd/lib/spin"; 5 | import Icon from "antd/lib/icon"; 6 | import message from "antd/lib/message"; 7 | import { defaultProps, propTypes, contextTypes } from "./helpers"; 8 | 9 | class SampleModule extends Component { 10 | componentDidMount() { 11 | this.init(); 12 | } 13 | 14 | componentWillUnmount() { 15 | this.props.reduxAction.getSampleModuleCancel(); 16 | } 17 | 18 | init = () => { 19 | console.log("Component Mounted"); 20 | }; 21 | 22 | getSampleModuleData = () => { 23 | this.props.reduxAction.getSampleModuleEpic(); 24 | }; 25 | 26 | getSampleModuleDataCancel = () => { 27 | message.warning("request has been cancelled"); 28 | this.props.reduxAction.getSampleModuleCancel(); 29 | }; 30 | 31 | clearData = () => { 32 | this.props.reduxAction.clearData(); 33 | }; 34 | 35 | render() { 36 | const { sampleModuleList, isLoading } = this.props; 37 | console.log(sampleModuleList, isLoading); 38 | return ( 39 |
40 |

Sample Module

41 | 45 | 48 | 51 | {isLoading ? ( 52 |
53 | 54 |
55 | ) : ( 56 | {item.title}} 62 | /> 63 | )} 64 |
65 | ); 66 | } 67 | } 68 | 69 | SampleModule.propTypes = propTypes; 70 | SampleModule.defaultProps = defaultProps; 71 | SampleModule.contextTypes = contextTypes; 72 | 73 | export default SampleModule; 74 | -------------------------------------------------------------------------------- /src/containers/sample-module/components/SubComponents.jsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nenjotsu/advanced-redux-pattern/dcdb78e9dabdf0b3f760ea6db756aaf2d3f4b5c7/src/containers/sample-module/components/SubComponents.jsx -------------------------------------------------------------------------------- /src/containers/sample-module/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { defaultProps, propTypes, contextTypes } from "./staticProps"; 2 | 3 | export { defaultProps, propTypes, contextTypes }; 4 | -------------------------------------------------------------------------------- /src/containers/sample-module/helpers/staticProps.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | export const contextTypes = { 4 | router: PropTypes.shape({ 5 | history: PropTypes.object.isRequired 6 | }) 7 | }; 8 | 9 | export const propTypes = { 10 | reduxAction: PropTypes.object.isRequired, 11 | sampleModuleList: PropTypes.array, 12 | isLoading: PropTypes.bool 13 | }; 14 | 15 | export const defaultProps = { 16 | sampleModuleList: [], 17 | isLoading: [] 18 | }; 19 | -------------------------------------------------------------------------------- /src/containers/sample-module/index.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from "redux"; 2 | import { connect } from "react-redux"; 3 | import View from "./View"; 4 | import * as actions from "../../redux/actions/sample-module"; 5 | 6 | function mapStateToProps(state) { 7 | return { 8 | isLoading: state.ui.isLoading, 9 | sampleModuleList: state.sampleModule.sampleModuleList 10 | }; 11 | } 12 | 13 | function mapDispatchToProps(dispatch) { 14 | return { 15 | reduxAction: bindActionCreators({ ...actions }, dispatch), 16 | dispatch 17 | }; 18 | } 19 | 20 | export default connect( 21 | mapStateToProps, 22 | mapDispatchToProps 23 | )(View); 24 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const loadState = () => { 2 | try { 3 | const serializedState = sessionStorage.getItem("state"); 4 | if (serializedState === null) { 5 | return undefined; 6 | } 7 | return JSON.parse(serializedState); 8 | } catch (err) { 9 | return undefined; 10 | } 11 | }; 12 | 13 | const domain = "https://jsonplaceholder.typicode.com"; 14 | 15 | export const url = { 16 | posts: `${domain}/posts`, 17 | comments: `${domain}/comments` 18 | }; 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import List from "antd/lib/list"; 5 | import Icon from "antd/lib/icon"; 6 | import store from "./redux/store"; 7 | import SampleModule from "./containers/sample-module"; 8 | 9 | import "antd/dist/antd.css"; 10 | import "./styles.css"; 11 | 12 | const data = ["Scalable", "Reusable", "Maintainable", "Multiple dispatch"]; 13 | 14 | function App() { 15 | return ( 16 |
17 |

Advanced Redux Pattern

18 |

19 | Using redux, redux-observables, rxjs, react-actions, middleware, 20 | ant-design 21 |

22 | ( 27 | 28 | 29 | {item} 30 | 31 | )} 32 | /> 33 |
34 | 35 |
36 |
37 | ); 38 | } 39 | 40 | const rootElement = document.getElementById("root"); 41 | ReactDOM.render( 42 | 43 | 44 | , 45 | rootElement 46 | ); 47 | -------------------------------------------------------------------------------- /src/redux/actions/api/epics.js: -------------------------------------------------------------------------------- 1 | import qs from "query-string"; 2 | import { ajax } from "rxjs/observable/dom/ajax"; 3 | import * as TYPE from "./types"; 4 | import { showSpinner, hideSpinner } from "../ui"; 5 | import { onErrorApi } from "../error"; 6 | import { retryStrategy, headersJson } from "./helpers"; 7 | import "rxjs"; 8 | 9 | export const getAjaxRequestEpic = action$ => 10 | action$.ofType(TYPE.GET_AJAX_REQUEST_EPIC).mergeMap(action => { 11 | const { url, onSuccess, onCancel, body } = action.payload; 12 | const inlineQuery = body === null ? "" : `?${qs.stringify(body)}`; 13 | return ajax 14 | .get(`${url}${inlineQuery}`, headersJson) 15 | .retryWhen(retryStrategy) 16 | .mergeMap(result => [onSuccess(result.response)]) 17 | .catch(err => [onErrorApi(err), hideSpinner()]) 18 | .startWith(showSpinner()) 19 | .takeUntil(action$.ofType(onCancel)); 20 | }); 21 | 22 | export const patchAjaxRequestEpic = action$ => 23 | action$.ofType(TYPE.PATCH_AJAX_REQUEST_EPIC).mergeMap(action => { 24 | const { url, onSuccess, onCancel, body } = action.payload; 25 | return ajax 26 | .patch(url, body, headersJson) 27 | .retryWhen(retryStrategy) 28 | .mergeMap(result => [onSuccess(result.response)]) 29 | .catch(err => [onErrorApi(err), hideSpinner()]) 30 | .startWith(showSpinner()) 31 | .takeUntil(action$.ofType(onCancel)); 32 | }); 33 | 34 | export const postAjaxRequestEpic = action$ => 35 | action$.ofType(TYPE.POST_AJAX_REQUEST_EPIC).mergeMap(action => { 36 | const { url, onSuccess, onCancel, body } = action.payload; 37 | return ajax 38 | .post(url, body, headersJson) 39 | .retryWhen(retryStrategy) 40 | .mergeMap(result => [onSuccess(result.response)]) 41 | .catch(err => [onErrorApi(err), hideSpinner()]) 42 | .startWith(showSpinner()) 43 | .takeUntil(action$.ofType(onCancel)); 44 | }); 45 | 46 | export const putAjaxRequestEpic = action$ => 47 | action$.ofType(TYPE.PUT_AJAX_REQUEST_EPIC).mergeMap(action => { 48 | const { url, onSuccess, onCancel, body } = action.payload; 49 | return ajax 50 | .post(url, body, headersJson) 51 | .retryWhen(retryStrategy) 52 | .mergeMap(result => [onSuccess(result.response)]) 53 | .catch(err => [onErrorApi(err), hideSpinner()]) 54 | .startWith(showSpinner()) 55 | .takeUntil(action$.ofType(onCancel)); 56 | }); 57 | export const deleteAjaxRequestEpic = action$ => 58 | action$.ofType(TYPE.PUT_AJAX_REQUEST_EPIC).mergeMap(action => { 59 | const { url, onSuccess, onCancel, body } = action.payload; 60 | return ajax 61 | .delete(`${url}?${qs.stringify(body)}`, headers) 62 | .retryWhen(retryStrategy) 63 | .mergeMap(result => [onSuccess(result.response)]) 64 | .catch(err => [onErrorApi(err), hideSpinner()]) 65 | .startWith(showSpinner()) 66 | .takeUntil(action$.ofType(onCancel)); 67 | }); 68 | 69 | export default [ 70 | getAjaxRequestEpic, 71 | patchAjaxRequestEpic, 72 | postAjaxRequestEpic, 73 | putAjaxRequestEpic, 74 | deleteAjaxRequestEpic 75 | ]; 76 | -------------------------------------------------------------------------------- /src/redux/actions/api/helpers.js: -------------------------------------------------------------------------------- 1 | export const retryStrategy = errors => 2 | errors 3 | .scan((_, value, index) => { 4 | if (index < 2) { 5 | return index; 6 | } 7 | throw value; 8 | }) 9 | .delay(5000); 10 | 11 | export const headersJson = { 12 | "Content-Type": "application/json" 13 | // Authorization: `Bearer ${TOKEN}`, 14 | }; 15 | -------------------------------------------------------------------------------- /src/redux/actions/api/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from "redux-actions"; 2 | import * as TYPE from "./types"; 3 | 4 | export const getAjaxRequestEpic = createAction(TYPE.GET_AJAX_REQUEST_EPIC); 5 | -------------------------------------------------------------------------------- /src/redux/actions/api/types.js: -------------------------------------------------------------------------------- 1 | export const GET_AJAX_REQUEST_EPIC = "[app] API Get Ajax Request"; 2 | export const PATCH_AJAX_REQUEST_EPIC = "[app] API Patch Ajax Request"; 3 | export const POST_AJAX_REQUEST_EPIC = "[app] API Post Ajax Request"; 4 | export const PUT_AJAX_REQUEST_EPIC = "[app] API Put Ajax Request"; 5 | export const DELETE_AJAX_REQUEST_EPIC = "[app] API Delete Ajax Request"; 6 | -------------------------------------------------------------------------------- /src/redux/actions/error/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from "redux-actions"; 2 | import * as TYPE from "./types"; 3 | 4 | export const onErrorApi = createAction(TYPE.ON_ERROR_API); 5 | -------------------------------------------------------------------------------- /src/redux/actions/error/types.js: -------------------------------------------------------------------------------- 1 | export const ON_ERROR_API = "[error] api"; 2 | -------------------------------------------------------------------------------- /src/redux/actions/index.js: -------------------------------------------------------------------------------- 1 | import { combineEpics } from "redux-observable"; 2 | 3 | import API from "./api/epics"; 4 | 5 | const rootEpic = (action$, store) => 6 | combineEpics(...API)(action$, store).catch((error, stream) => { 7 | console.error(error); 8 | return stream; 9 | }); 10 | 11 | export default rootEpic; 12 | -------------------------------------------------------------------------------- /src/redux/actions/sample-module/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from "redux-actions"; 2 | import * as TYPE from "./types"; 3 | 4 | export const getSampleModuleEpic = createAction(TYPE.GET_SAMPLE_MODULE_EPIC); 5 | export const getSampleModuleSuccess = createAction( 6 | TYPE.GET_SAMPLE_MODULE_SUCCESS 7 | ); 8 | export const getSampleModuleCancel = createAction( 9 | TYPE.GET_SAMPLE_MODULE_CANCEL 10 | ); 11 | export const getSampleModule = createAction(TYPE.GET_SAMPLE_MODULE); 12 | 13 | export const clearData = createAction(TYPE.CLEAR_DATA); 14 | -------------------------------------------------------------------------------- /src/redux/actions/sample-module/types.js: -------------------------------------------------------------------------------- 1 | export const GET_SAMPLE_MODULE_EPIC = "[sample_module_name] Get Epic"; // to middleware 2 | export const GET_SAMPLE_MODULE_SUCCESS = "[sample_module_name] Get Success"; 3 | export const GET_SAMPLE_MODULE_CANCEL = "[sample_module_name] Get Cancel"; 4 | export const GET_SAMPLE_MODULE = "[sample_module_name] Get"; 5 | 6 | export const CLEAR_DATA = "[sample_module_name] Clear Data"; 7 | -------------------------------------------------------------------------------- /src/redux/actions/ui/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from "redux-actions"; 2 | import * as TYPE from "./types"; 3 | 4 | export const showSpinner = createAction(TYPE.SHOW_SPINNER); 5 | export const hideSpinner = createAction(TYPE.HIDE_SPINNER); 6 | -------------------------------------------------------------------------------- /src/redux/actions/ui/types.js: -------------------------------------------------------------------------------- 1 | export const SHOW_SPINNER = "[ui] Show spinner"; 2 | export const HIDE_SPINNER = "[ui] Hide spinner"; 3 | -------------------------------------------------------------------------------- /src/redux/middleware/error/index.js: -------------------------------------------------------------------------------- 1 | import * as TYPE from "../../actions/error/types"; 2 | 3 | const onErrorApi = ({ dispatch }) => next => action => { 4 | next(action); 5 | 6 | if (action.type === TYPE.ON_ERROR_API) { 7 | console.error("error", action.payload, dispatch); 8 | } 9 | }; 10 | 11 | export default [onErrorApi]; 12 | -------------------------------------------------------------------------------- /src/redux/middleware/multi-dispatch/index.js: -------------------------------------------------------------------------------- 1 | const multi = ({ dispatch }) => next => action => { 2 | if (Array.isArray(action)) { 3 | return action.filter(Boolean).map(dispatch); 4 | } 5 | return next(action); 6 | }; 7 | 8 | export default multi; 9 | -------------------------------------------------------------------------------- /src/redux/middleware/sample-module/index.js: -------------------------------------------------------------------------------- 1 | import { getAjaxRequestEpic } from "../../actions/api"; 2 | import { 3 | getSampleModule, 4 | getSampleModuleSuccess 5 | } from "../../actions/sample-module"; 6 | 7 | import * as TYPE from "../../actions/sample-module/types"; 8 | import { url } from "../../../helpers"; 9 | import { showSpinner, hideSpinner } from "../../actions/ui"; 10 | 11 | const getSampleModuleFlow = ({ dispatch }) => next => action => { 12 | next(action); 13 | 14 | if (action.type === TYPE.GET_SAMPLE_MODULE_EPIC) { 15 | dispatch([ 16 | showSpinner(), 17 | getAjaxRequestEpic({ 18 | url: url.posts, 19 | body: null, 20 | onCancel: TYPE.GET_SAMPLE_MODULE_CANCEL, 21 | onSuccess: getSampleModuleSuccess 22 | }) 23 | ]); 24 | } 25 | }; 26 | 27 | const getSampleModuleSuccessFlow = ({ dispatch }) => next => action => { 28 | next(action); 29 | 30 | if (action.type === TYPE.GET_SAMPLE_MODULE_SUCCESS) { 31 | dispatch([getSampleModule(action.payload), hideSpinner()]); 32 | } 33 | }; 34 | 35 | const getSampleModuleSuccessCancelFlow = ({ dispatch }) => next => action => { 36 | next(action); 37 | 38 | if (action.type === TYPE.GET_SAMPLE_MODULE_CANCEL) { 39 | dispatch(hideSpinner()); 40 | } 41 | }; 42 | 43 | export default [ 44 | getSampleModuleFlow, 45 | getSampleModuleSuccessFlow, 46 | getSampleModuleSuccessCancelFlow 47 | ]; 48 | -------------------------------------------------------------------------------- /src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import sampleModule from "./sample-module"; 3 | import ui from "./ui"; 4 | 5 | export default combineReducers({ 6 | ui, 7 | sampleModule 8 | }); 9 | -------------------------------------------------------------------------------- /src/redux/reducers/sample-module/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from "redux-actions"; 2 | import * as ACTION from "../../actions/sample-module"; 3 | import model from "./model"; 4 | 5 | export default handleActions( 6 | { 7 | [ACTION.getSampleModule]: (state, action) => { 8 | console.log("action.payload", action.payload); 9 | return { 10 | ...state, 11 | sampleModuleList: action.payload 12 | }; 13 | }, 14 | [ACTION.clearData]: (state, action) => ({ 15 | ...state, 16 | sampleModuleList: [] 17 | }) 18 | }, 19 | model 20 | ); 21 | -------------------------------------------------------------------------------- /src/redux/reducers/sample-module/model.js: -------------------------------------------------------------------------------- 1 | export default { 2 | candidatesCenterList: { 3 | pageSize: 10, 4 | pageCurrent: 0 5 | }, 6 | candidateStatus: [], 7 | auctionIdsAssignedToMe: [], 8 | candidateNamesByClient: [], 9 | keyword: "" 10 | }; 11 | -------------------------------------------------------------------------------- /src/redux/reducers/ui/index.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from "redux-actions"; 2 | import * as ACTION from "../../actions/ui"; 3 | import model from "./model"; 4 | 5 | export default handleActions( 6 | { 7 | [ACTION.showSpinner]: state => { 8 | console.log("show spinner count"); 9 | return { 10 | ...state, 11 | isLoading: true 12 | }; 13 | }, 14 | [ACTION.hideSpinner]: state => { 15 | console.log("hide spinner count"); 16 | return { 17 | ...state, 18 | isLoading: false 19 | }; 20 | } 21 | }, 22 | model 23 | ); 24 | -------------------------------------------------------------------------------- /src/redux/reducers/ui/model.js: -------------------------------------------------------------------------------- 1 | export default { 2 | isLoading: false 3 | }; 4 | -------------------------------------------------------------------------------- /src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore, compose } from "redux"; 2 | import { createEpicMiddleware } from "redux-observable"; 3 | import combinedReducers from "./reducers"; 4 | import combinedEpics from "./actions"; 5 | import { loadState } from "../helpers"; 6 | import multiDispatch from "./middleware/multi-dispatch"; 7 | import sampleModule from "./middleware/sample-module"; 8 | import error from "./middleware/error"; 9 | 10 | const composeEnhancers = 11 | process.env.NODE_ENV === "development" 12 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 13 | : null || compose; 14 | 15 | const stateFromLocalStorage = loadState(); 16 | 17 | const epicMiddleware = createEpicMiddleware(combinedEpics); 18 | 19 | const store = createStore( 20 | combinedReducers, 21 | stateFromLocalStorage, 22 | composeEnhancers( 23 | applyMiddleware(epicMiddleware, multiDispatch, ...sampleModule, ...error) 24 | ) 25 | ); 26 | 27 | export default store; 28 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: sans-serif; 3 | text-align: center; 4 | padding: 20px; 5 | } 6 | 7 | .icon { 8 | margin-right: 10px; 9 | margin-top: 4px; 10 | color: green; 11 | } 12 | 13 | .button-list { 14 | margin-top: 10px; 15 | } 16 | 17 | .btn { 18 | margin-right: 5px; 19 | } 20 | 21 | .data-list { 22 | margin-top: 10px; 23 | } 24 | 25 | .spin-container { 26 | text-align: center; 27 | background: rgba(0, 0, 0, 0.05); 28 | border-radius: 4px; 29 | margin-bottom: 20px; 30 | padding: 30px 50px; 31 | margin: 20px 0; 32 | } 33 | --------------------------------------------------------------------------------