├── .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 |
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 |
20 | {todos.map((todo, index) => )
21 | .toArray()}
22 |
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 |
--------------------------------------------------------------------------------