├── .eslintrc ├── .gitignore ├── README.md ├── dist └── index.html ├── karma.conf.js ├── package.json ├── src ├── client │ ├── actions │ │ └── todoActions.js │ ├── components │ │ ├── InsertTodoForm.jsx │ │ ├── PureControllerView.jsx │ │ ├── TodoItem.jsx │ │ ├── TodoItemsList.jsx │ │ └── TodoList.jsx │ ├── constants │ │ ├── actions.js │ │ └── effects.js │ ├── effects-handlers │ │ └── apiCallEffectHandler.js │ ├── main.jsx │ ├── messageBuilder.js │ └── reducers │ │ ├── masterReducer.js │ │ └── todoListReducer.js ├── server │ ├── devServer.js │ └── stageServer.js └── spec │ └── todoList.spec.js └── webpack ├── webpack.development.config.js └── webpack.staging.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", // https://github.com/babel/babel-eslint 3 | "plugins": [ 4 | "react" // https://github.com/yannickcr/eslint-plugin-react 5 | ], 6 | "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments 7 | "browser": true, // browser global variables 8 | "node": true // Node.js global variables and Node.js-specific rules 9 | }, 10 | "ecmaFeatures": { 11 | "arrowFunctions": true, 12 | "blockBindings": true, 13 | "classes": true, 14 | "defaultParams": true, 15 | "destructuring": true, 16 | "forOf": true, 17 | "generators": false, 18 | "modules": true, 19 | "objectLiteralComputedProperties": true, 20 | "objectLiteralDuplicateProperties": false, 21 | "objectLiteralShorthandMethods": true, 22 | "objectLiteralShorthandProperties": true, 23 | "spread": true, 24 | "superInFunctions": true, 25 | "templateStrings": true, 26 | "jsx": true 27 | }, 28 | "rules": { 29 | /** 30 | * Strict mode 31 | */ 32 | // babel inserts "use strict"; for us 33 | "strict": [2, "never"], // http://eslint.org/docs/rules/strict 34 | 35 | /** 36 | * ES6 37 | */ 38 | "no-var": 2, // http://eslint.org/docs/rules/no-var 39 | "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const 40 | 41 | /** 42 | * Variables 43 | */ 44 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 45 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 46 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 47 | "vars": "local", 48 | "args": "after-used" 49 | }], 50 | "no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define 51 | 52 | /** 53 | * Possible errors 54 | */ 55 | "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle 56 | "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign 57 | "no-console": 1, // http://eslint.org/docs/rules/no-console 58 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 59 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 60 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 61 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 62 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 63 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 64 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 65 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 66 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 67 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 68 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 69 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 70 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 71 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 72 | "no-reserved-keys": 2, // http://eslint.org/docs/rules/no-reserved-keys 73 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 74 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 75 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 76 | "block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var 77 | 78 | /** 79 | * Best practices 80 | */ 81 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 82 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 83 | "default-case": 2, // http://eslint.org/docs/rules/default-case 84 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 85 | "allowKeywords": true 86 | }], 87 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 88 | "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in 89 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 90 | "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return 91 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 92 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 93 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 94 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 95 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 96 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 97 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 98 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 99 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 100 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 101 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 102 | "no-new": 2, // http://eslint.org/docs/rules/no-new 103 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 104 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 105 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 106 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 107 | "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign 108 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 109 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 110 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 111 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 112 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 113 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 114 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 115 | "no-with": 2, // http://eslint.org/docs/rules/no-with 116 | "radix": 2, // http://eslint.org/docs/rules/radix 117 | "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top 118 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 119 | "yoda": 2, // http://eslint.org/docs/rules/yoda 120 | 121 | /** 122 | * Style 123 | */ 124 | "indent": [2, 2], // http://eslint.org/docs/rules/indent 125 | "brace-style": [2, // http://eslint.org/docs/rules/brace-style 126 | "1tbs", { 127 | "allowSingleLine": true 128 | }], 129 | "quotes": [ 130 | 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 131 | ], 132 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 133 | "properties": "never" 134 | }], 135 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 136 | "before": false, 137 | "after": true 138 | }], 139 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 140 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 141 | "func-names": 1, // http://eslint.org/docs/rules/func-names 142 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 143 | "beforeColon": false, 144 | "afterColon": true 145 | }], 146 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 147 | "newIsCap": true 148 | }], 149 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 150 | "max": 2 151 | }], 152 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 153 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 154 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 155 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 156 | "no-wrap-func": 2, // http://eslint.org/docs/rules/no-wrap-func 157 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 158 | "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var 159 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 160 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 161 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 162 | "before": false, 163 | "after": true 164 | }], 165 | "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords 166 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 167 | "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren 168 | "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops 169 | "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case 170 | "spaced-line-comment": 2, // http://eslint.org/docs/rules/spaced-line-comment 171 | 172 | /** 173 | * JSX style 174 | */ 175 | "react/display-name": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/display-name.md 176 | "react/jsx-boolean-value": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-boolean-value.md 177 | "react/jsx-quotes": [2, "double"], // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-quotes.md 178 | "react/jsx-no-undef": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-undef.md 179 | "react/jsx-sort-props": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-props.md 180 | "react/jsx-sort-prop-types": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-prop-types.md 181 | "react/jsx-uses-react": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-react.md 182 | "react/jsx-uses-vars": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-vars.md 183 | "react/no-did-mount-set-state": [2, "allow-in-func"], // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-did-mount-set-state.md 184 | "react/no-did-update-set-state": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-did-update-set-state.md 185 | "react/no-multi-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-multi-comp.md 186 | "react/no-unknown-property": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md 187 | "react/prop-types": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/prop-types.md 188 | "react/react-in-jsx-scope": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/react-in-jsx-scope.md 189 | "react/self-closing-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md 190 | "react/wrap-multilines": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/wrap-multilines.md 191 | "react/sort-comp": [2, { // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md 192 | "order": [ 193 | "displayName", 194 | "propTypes", 195 | "contextTypes", 196 | "childContextTypes", 197 | "mixins", 198 | "statics", 199 | "defaultProps", 200 | "constructor", 201 | "getDefaultProps", 202 | "getInitialState", 203 | "getChildContext", 204 | "componentWillMount", 205 | "componentDidMount", 206 | "componentWillReceiveProps", 207 | "shouldComponentUpdate", 208 | "componentWillUpdate", 209 | "componentDidUpdate", 210 | "componentWillUnmount", 211 | "/^on.+$/", 212 | "/^get.+$/", 213 | "/^render.+$/", 214 | "render" 215 | ] 216 | }] 217 | } 218 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/app.bundle.js 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flux boilerplate with stateless stores and reduced side effects 2 | 3 | This is a very simple repository demonstrating how can stateless stores (Reducers) help us to build 4 | more manageable Flux applications. It also utilizes Effect handlers to "reduce" side-effects from the 5 | Reducers (see below). It is fully hot-reloadable (reducers, actions, components). 6 | 7 | More details are available on the [JavaScripting Blog](https://blog.javascripting.com): 8 | 9 | - [Flux: No More Stores Meet Reducer](https://blog.javascripting.com/2015/06/19/flux-no-more-stores-meet-reducer/) 10 | - [Flux: Reduce Your Side Effects](https://blog.javascripting.com/2015/08/12/reduce-your-side-effects/) 11 | 12 | ## Architectural overview: 13 | 14 | There is a main store (which is not a Reducer because it's not reducing anything) in the top-level component. 15 | The store is registered in the dispatcher in the component's constructor function. The dispatcher is 16 | passed down the component hierarchy so that it's possible to dispatch an action from within any component. 17 | 18 | ```javascript 19 | constructor() { 20 | const Reduction = record({ 21 | appState: fromJS({ 22 | todos: [], 23 | loading: false 24 | }), 25 | effects: List.of() 26 | }); 27 | 28 | const dispatcher = new Dispatcher(); 29 | 30 | // This is actually the top-level store, composing Reducers and applying effect handlers. 31 | dispatcher.register((action) => { 32 | let reduction = this.state.reduction; 33 | 34 | // Let's store all actions so that we can replay them. 35 | const actionLog = this.state.actionLog.push(action); 36 | 37 | // We want to purge the list of effects before every action. 38 | reduction = reduction.set('effects', List.of()); 39 | 40 | // All Reducers are executed here. 41 | reduction = todoListReducer(reduction, action); 42 | 43 | // All effect handlers are handled here. 44 | reduction.get('effects').forEach(apiCallEffectHandler.bind(null, dispatcher)); 45 | 46 | // Let's set the reduction back to the Component's state, 47 | // This will result in re-render of any pure views whose 48 | // props have changed. 49 | this.setState({reduction, actionLog}); 50 | }); 51 | 52 | // We will keep the dispatcher, reduction and action log in the root component's state. 53 | // A portion of this state is passed down the component hierarchy to the corresponding 54 | // components. 55 | this.state = { 56 | dispatcher: dispatcher, 57 | reduction: new Reduction(), 58 | actionLog: List.of() // This is only for debugging, so we replay actions 59 | }; 60 | } 61 | ``` 62 | 63 | The action is passed to the Reducer, The Reducer reduces both the list of effects and the new application state: 64 | ```javascript 65 | 66 | /** 67 | * When the user submits the form, they would expect to 68 | * see the loading spinner so that they know something is going on 69 | * in the background. 70 | * 71 | * There are two reactions to this action: 72 | * 1) Set the loading flag -> we can display loading spinner in the UI. 73 | * 2) Emit an effect which results in an API call (storing the Todo). 74 | * This effect is however not executed in the Reducer, 75 | * instead it reduced into the reduction's effect list as a "message". 76 | * The message is just a pair of type and payload. 77 | * 78 | * The Reducer does not take the dispatcher as a parameter, so action 79 | * chaining is not possible. 80 | */ 81 | const todoListReducer = (reduction, action) { 82 | switch (action.type) { 83 | case 'TODO_ADDING_REQUESTED': 84 | return reduction 85 | .setIn(['appState', 'loading'], true) 86 | .set('effects', reduction 87 | .get('effects') 88 | .push(buildMessage('INSERT_TODO_API_CALL', action.payload)) // action.payload contains the actual TODO 89 | ); 90 | break; 91 | } 92 | } 93 | ``` 94 | 95 | The list of effects is passed to the effect handler (this all happens in the top-level store). 96 | The handler handles the effect and results in some side effect: 97 | ```javascript 98 | 99 | const apiCallEffectHandler = (effect, dispatcher) { 100 | switch (effect.type) { 101 | case 'INSERT_TODO_API_CALL': 102 | mockApiCall(effect.payload).then(response => dispatcher.dispatch(todoAdded(response))); 103 | break; 104 | } 105 | } 106 | ``` 107 | 108 | 109 | ## Usage 110 | ``` 111 | git clone git@github.com:tomkis1/flux-boilerplate.git 112 | cd flux-boilerplate 113 | npm install 114 | npm start 115 | ``` 116 | 117 | ## Development 118 | ``` 119 | node src/server/devServer.js 120 | ``` 121 | 122 | ## Tests 123 | ``` 124 | npm test 125 | ``` 126 | Navigate your browser to http://localhost:9876/. 127 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TODOMVC 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | frameworks: ['mocha'], 4 | files: [ 5 | 'src/spec/**/*.spec.*' 6 | ], 7 | webpack: { 8 | module: { 9 | loaders: [{ 10 | test: /\.jsx$|\.js$/, 11 | loaders: ['babel'] 12 | }] 13 | } 14 | }, 15 | preprocessors: { 16 | 'src/spec/**/*.spec.js': ['webpack'] 17 | }, 18 | plugins: [ 19 | require('karma-webpack'), 20 | require('karma-mocha') 21 | ] 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flux-boilerplate", 3 | "version": "0.1.0", 4 | "description": "React Flux boilerplate", 5 | "scripts": { 6 | "postinstall": "webpack --config=webpack/webpack.staging.config.js", 7 | "start": "node src/server/stageServer.js", 8 | "test": "./node_modules/.bin/karma start", 9 | "lint": "./node_modules/.bin/eslint ./src/client" 10 | }, 11 | "author": "Tomas Weiss ", 12 | "devDependencies": { 13 | "babel-core": "^5.4.4", 14 | "babel-eslint": "^4.0.5", 15 | "babel-loader": "^5.1.2", 16 | "eslint": "^0.24.1", 17 | "eslint-plugin-react": "^3.1.0", 18 | "expect": "^1.6.0", 19 | "express": "^4.12.4", 20 | "karma": "^0.12.36", 21 | "karma-mocha": "^0.1.10", 22 | "karma-webpack": "^1.5.1", 23 | "mocha": "^2.2.5", 24 | "react-hot-loader": "^1.2.7", 25 | "webpack": "^1.9.7", 26 | "webpack-dev-server": "^1.8.2" 27 | }, 28 | "dependencies": { 29 | "babel-runtime": "^5.8.12", 30 | "flux": "^2.0.3", 31 | "immutable": "^3.7.3", 32 | "react": "^0.13.3", 33 | "react-pure-render": "^1.0.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/actions/todoActions.js: -------------------------------------------------------------------------------- 1 | import buildMessage from '../messageBuilder'; 2 | import * as Actions from '../constants/actions'; 3 | 4 | /** 5 | * Action creators are totally pure. They don't make any API calls or implement business logic. 6 | */ 7 | export const addTodo = todo => buildMessage(Actions.TODO_ADDING_REQUESTED, todo); 8 | export const todoAdded = todo => buildMessage(Actions.TODO_ADDED, todo); 9 | export const removeTodo = index => buildMessage(Actions.TODO_REMOVED, index); 10 | -------------------------------------------------------------------------------- /src/client/components/InsertTodoForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PureControllerView from './PureControllerView'; 4 | import { addTodo } from '../actions/todoActions'; 5 | 6 | export default class InsertTodoForm extends PureControllerView { 7 | 8 | onSubmit(ev) { 9 | ev.stopPropagation(); 10 | ev.preventDefault(); 11 | 12 | this.dispatchAction(addTodo(this.refs.input.getDOMNode().value)); 13 | } 14 | 15 | render() { 16 | return ( 17 |
18 | 19 |
20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/client/components/PureControllerView.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import shallowEqual from 'react-pure-render/shallowEqual'; 3 | 4 | /** 5 | * Implements shouldComponentUpdate 6 | */ 7 | export default class PureControllerView extends React.Component { 8 | 9 | // So yeah, we are using immutable data structures, 10 | // so it's totally ok to just compare props and state 11 | // to check if anything has changed. 12 | shouldComponentUpdate(nextProps, nextState) { 13 | return !shallowEqual(this.props, nextProps) || 14 | !shallowEqual(this.state, nextState); 15 | } 16 | 17 | dispatchAction(action) { 18 | this.props.dispatcher.dispatch(action); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/client/components/TodoItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import PureControllerView from './PureControllerView'; 4 | import { removeTodo } from '../actions/todoActions'; 5 | 6 | export default class TodoItem extends PureControllerView { 7 | 8 | static propTypes = { 9 | todo: PropTypes.string, 10 | index: PropTypes.number 11 | }; 12 | 13 | onClick() { 14 | if (confirm('Do you really want to remove this item?')) { 15 | this.dispatchAction(removeTodo(this.props.index)); 16 | } 17 | } 18 | 19 | render() { 20 | return ( 21 |
  • {this.props.todo}
  • 22 | ); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/client/components/TodoItemsList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Dispatcher } from 'flux'; 3 | import { List } from 'immutable'; 4 | 5 | import PureControllerView from './PureControllerView'; 6 | import TodoItem from './TodoItem'; 7 | 8 | export default class TodoItemsList extends PureControllerView { 9 | 10 | static propTypes = { 11 | todos: PropTypes.instanceOf(List), 12 | dispatcher: PropTypes.instanceOf(Dispatcher) 13 | }; 14 | 15 | render() { 16 | const {todos, dispatcher} = this.props; 17 | 18 | return ( 19 | 23 | ); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/client/components/TodoList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dispatcher } from 'flux'; 3 | import { Record as record, fromJS, List } from 'immutable'; 4 | 5 | import masterReducer from '../reducers/masterReducer'; 6 | import apiCallEffectHandler from '../effects-handlers/apiCallEffectHandler'; 7 | 8 | import InsertTodoForm from './InsertTodoForm'; 9 | import TodoItemsList from './TodoItemsList'; 10 | 11 | /** 12 | * This Reduction record describes the reduction schema returned by Reducers. 13 | * It contains a map that represents our single application state Atom 14 | * and it also contains list of effects. An Effect is just a message. 15 | * A message is a pair of type and payload. 16 | */ 17 | const Reduction = record({ 18 | appState: fromJS({ 19 | todos: [], 20 | loading: false 21 | }), 22 | effects: List.of() 23 | }); 24 | 25 | export default class TodoList extends React.Component { 26 | 27 | constructor() { 28 | super(); 29 | 30 | const dispatcher = new Dispatcher(); 31 | 32 | // This is actually the top-level store, composing Reducers and applying effect handlers. 33 | dispatcher.register((action) => { 34 | let reduction = this.state.reduction; 35 | 36 | // Let's store all actions so that we can replay them. 37 | const actionLog = this.state.actionLog.push(action); 38 | 39 | // We want to purge the list of effects before every action. 40 | reduction = reduction.set('effects', List.of()); 41 | 42 | // Only master reducer is executed here 43 | reduction = masterReducer(reduction, action); 44 | 45 | // All effect handlers are handled here. 46 | reduction.get('effects').forEach(apiCallEffectHandler.bind(null, dispatcher)); 47 | 48 | // Let's set the reduction back to the Component's state, 49 | // This will result in re-render of any pure views whose 50 | // props have changed. 51 | this.setState({reduction, actionLog}); 52 | }); 53 | 54 | // We will keep the dispatcher, reduction and action log in the root component's state. 55 | // A portion of this state is passed down the component hierarchy to the corresponding 56 | // components. 57 | this.state = { 58 | dispatcher: dispatcher, 59 | reduction: new Reduction(), 60 | actionLog: List.of() // This is only for debugging, so we replay actions 61 | }; 62 | 63 | // If there is hot-reloading available: 64 | // We want to replay all actions after the code has been refreshed 65 | if (module.hot) { 66 | module.hot.addStatusHandler(() => setTimeout(() => window.replay())); 67 | } 68 | } 69 | 70 | componentDidUpdate() { 71 | // The method is here only for hot-reloading. 72 | window.replay = () => { 73 | // We take the action log and reduce it in the master reducer, 74 | // passing it an initial empty reduction. 75 | // We empty the effect list so that we don't replay them. 76 | const reduction = this.state 77 | .actionLog 78 | .reduce(masterReducer, new Reduction()) 79 | .set('effects', List.of()); 80 | 81 | this.setState({reduction}); 82 | }; 83 | } 84 | 85 | render() { 86 | if (this.state.reduction.getIn(['appState', 'loading'])) { 87 | return
    API call in progress
    ; 88 | } 89 | 90 | return ( 91 |
    92 | 93 | 94 |
    95 | ); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/client/constants/actions.js: -------------------------------------------------------------------------------- 1 | export const TODO_ADDED = 'TODO_ADDED'; 2 | export const TODO_ADDING_REQUESTED = 'TODO_ADDING_REQUESTED'; 3 | export const TODO_REMOVED = 'TODO_REMOVED'; 4 | -------------------------------------------------------------------------------- /src/client/constants/effects.js: -------------------------------------------------------------------------------- 1 | export const INSERT_TODO_API_CALL = 'INSERT_TODO_API_CALL'; 2 | -------------------------------------------------------------------------------- /src/client/effects-handlers/apiCallEffectHandler.js: -------------------------------------------------------------------------------- 1 | import { Map as map } from 'immutable'; 2 | 3 | import { todoAdded } from '../actions/todoActions'; 4 | import * as Effects from '../constants/effects'; 5 | 6 | /** 7 | * Just a helper method (functional way) to return a function which 8 | * calls the appropriate handler. 9 | */ 10 | const buildEffectHandler = handlers => { 11 | return (dispatcher, effect) => { 12 | map(handlers) // Wrap it in an immutable map so that we can use fancy methods like `filter`. 13 | .filter((handler, effectType) => effectType === effect.type) 14 | .forEach(handler => handler(effect.payload, dispatcher)); 15 | }; 16 | }; 17 | 18 | // Let's simulate the server 19 | const mockApiCall = (request) => new Promise(res => setTimeout(() => res(request + '-server-modified'), 500)); 20 | 21 | export default buildEffectHandler({ 22 | 23 | /** 24 | * This method takes an effect which has some payload (a todo in this case) 25 | * and results in some side effect (in this case an API call). 26 | * Once the call is finished, we dispatch another action with the response as its payload. 27 | * 28 | * There are only two parameters: payload of the effect and the dispatcher, 29 | * therefore you can't directly modify the state but can only dispatch an action instead. 30 | */ 31 | [Effects.INSERT_TODO_API_CALL]: (todo, dispatcher) => { 32 | mockApiCall(todo).then(response => dispatcher.dispatch(todoAdded(response))); 33 | } 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/client/main.jsx: -------------------------------------------------------------------------------- 1 | import React, { render } from 'react'; 2 | 3 | import TodoList from './components/TodoList'; 4 | 5 | render(, document.getElementById('app')); 6 | -------------------------------------------------------------------------------- /src/client/messageBuilder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * We use this method to build a message. 3 | * A message is actually an action or an effect, both of which share the same characteristics. 4 | */ 5 | export default (type, payload) => { return {type, payload}; }; 6 | -------------------------------------------------------------------------------- /src/client/reducers/masterReducer.js: -------------------------------------------------------------------------------- 1 | // Master reducer idea inspired by http://www.code-experience.com/problems-with-flux/ 2 | // We have to handle the action only in single top level reducer. 3 | 4 | import * as Actions from '../constants/actions'; 5 | import * as TodoListReducer from './todoListReducer'; 6 | 7 | export default (reduction, action) => { 8 | const payload = action.payload; 9 | const type = action.type; 10 | 11 | console.debug('Handling action', type); 12 | 13 | return reduction.withMutations(mutableReduction => { 14 | switch (type) { 15 | case Actions.TODO_ADDING_REQUESTED: 16 | mutableReduction.update(r => TodoListReducer.todoAddingRequested(r, payload)); 17 | break; 18 | case Actions.TODO_ADDED: 19 | mutableReduction.update(r => TodoListReducer.todoAdded(r, payload)); 20 | break; 21 | case Actions.TODO_REMOVED: 22 | mutableReduction.update(r => TodoListReducer.todoRemoved(r, payload)); 23 | break; 24 | default: 25 | console.debug(`Unhandled action ${type}`); 26 | } 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /src/client/reducers/todoListReducer.js: -------------------------------------------------------------------------------- 1 | import * as Effects from '../constants/effects'; 2 | import buildMessage from '../messageBuilder'; 3 | 4 | /** 5 | * When the user submits the form, they would expect to 6 | * see the loading spinner so that they know something is going on 7 | * in the background. 8 | * 9 | * There are two reactions to this action: 10 | * 1) Set the loading flag -> we can display loading spinner in the UI. 11 | * 2) Emit an effect which results in an API call (storing the Todo). 12 | * This effect is however not executed in the Reducer, 13 | * instead it reduced into the reduction's effect list as a "message". 14 | * The message is just a pair of type and payload. 15 | * 16 | * The Reducer does not take the dispatcher as a parameter, so action 17 | * chaining is not possible 18 | */ 19 | export const todoAddingRequested = (reduction, todo) => reduction 20 | .setIn(['appState', 'loading'], true) 21 | .set('effects', reduction 22 | .get('effects') 23 | .push(buildMessage(Effects.INSERT_TODO_API_CALL, todo)) 24 | ); 25 | 26 | /** 27 | * The effect handler "executes" the effect, resulting in a side effect. 28 | * When the side-effect obtains the response from the server it dispatches 29 | * a new TODO_ADDED action. 30 | * 31 | * We would like to 32 | * 1) Reset the loading flag -> hide loading spinner beacuse API call has completed. 33 | * 2) Push the todo (it might have been modified on the server) into the list of todos. 34 | * 35 | * Be aware that this is just an illustrative example. Ideally, you don't need to modify 36 | * the Todo being added on the server, and therefore you can put it in the list before the API call. 37 | */ 38 | export const todoAdded = (reduction, todo) => reduction 39 | .setIn(['appState', 'loading'], false) 40 | .setIn(['appState', 'todos'], reduction.getIn(['appState', 'todos']).push(todo)); 41 | 42 | export const todoRemoved = (reduction, index) => reduction.deleteIn(['appState', 'todos', index]); 43 | -------------------------------------------------------------------------------- /src/server/devServer.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var webpackDevServer = require('webpack-dev-server'); 3 | var config = require('../../webpack/webpack.development.config'); 4 | 5 | new webpackDevServer(webpack(config), { 6 | contentBase: './dist', 7 | hot: true, 8 | historyApiFallback: true 9 | }).listen(3000); 10 | -------------------------------------------------------------------------------- /src/server/stageServer.js: -------------------------------------------------------------------------------- 1 | var Express = require('express'); 2 | 3 | var server = Express(); 4 | server.use('/', Express.static('./dist')); 5 | server.listen(process.env.PORT || 3000); 6 | -------------------------------------------------------------------------------- /src/spec/todoList.spec.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import expect from 'expect'; 3 | 4 | import * as TodoActions from '../client/actions/TodoActions'; 5 | import * as TodoListReducer from '../client/reducers/todoListReducer'; 6 | import masterReducer from '../client/reducers/masterReducer'; 7 | 8 | const TESTING_TODO = 'test'; 9 | 10 | // It's pretty easy to write either integration or unit tests. 11 | // Because we have master reducer we can simply do the integration test by calling it. 12 | // All other reducer's functions can be tested in isolation as the unit test. 13 | describe('Todo list', () => { 14 | let reduction; 15 | 16 | beforeEach(() => { 17 | reduction = fromJS({ 18 | appState: { 19 | todos: [], 20 | loading: false 21 | }, 22 | effects: [] 23 | }); 24 | }); 25 | 26 | // This is obviously an integration test beacuse it's testing entire masterReducer which may call multiple reducers functions. 27 | it('should toggle loader and fire API call to store todo after adding todo - integration test', () => { 28 | const addTodoAction = TodoActions.addTodo(TESTING_TODO); 29 | reduction = masterReducer(reduction, addTodoAction); 30 | 31 | expect(reduction.getIn(['appState', 'loading'])).toBe(true); 32 | expect(reduction.getIn(['effects']).count()).toBe(1); // This should be more sophisticated. 33 | }); 34 | 35 | // This is unit test as it's testing just the single pure todoAdded function. 36 | it('should reset loader and contain new todo - unit test', () => { 37 | const todoAddedAction = TodoActions.todoAdded(TESTING_TODO); 38 | reduction = TodoListReducer.todoAdded(reduction, todoAddedAction.payload); 39 | 40 | expect(reduction.getIn(['appState', 'loading'])).toBe(false); 41 | expect(reduction.getIn(['appState', 'todos', 0])).toBe(TESTING_TODO); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /webpack/webpack.development.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | debug: true, 6 | devtool: 'sourcemap', 7 | plugins: [ 8 | new webpack.HotModuleReplacementPlugin(), 9 | new webpack.NoErrorsPlugin() 10 | ], 11 | entry: [ 12 | 'webpack-dev-server/client?http://localhost:3000', 13 | 'webpack/hot/only-dev-server', 14 | './src/client/main.jsx' 15 | ], 16 | output: { 17 | path: path.join(__dirname, '../dist'), 18 | filename: 'app.bundle.js' 19 | }, 20 | module: { 21 | loaders: [{ 22 | test: /\.jsx$|\.js$/, 23 | loaders: ['react-hot', 'babel?stage=0&optional[]=runtime'], 24 | include: path.join(__dirname, '../src/client') 25 | }] 26 | }, 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /webpack/webpack.staging.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: [ 6 | './src/client/main.jsx' 7 | ], 8 | plugins: [ 9 | new webpack.optimize.UglifyJsPlugin() 10 | ], 11 | output: { 12 | path: path.join(__dirname, '../dist'), 13 | filename: 'app.bundle.js' 14 | }, 15 | module: { 16 | loaders: [{ 17 | test: /\.jsx$|\.js$/, 18 | loaders: ['babel?stage=0&optional[]=runtime'], 19 | include: path.join(__dirname, '../src/client') 20 | }] 21 | }, 22 | resolve: { 23 | extensions: ['', '.js', '.jsx'] 24 | } 25 | }; 26 | --------------------------------------------------------------------------------