├── .babelrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── examples └── localStorageTodo │ ├── .editorconfig │ ├── .eslintrc │ ├── .npmignore │ ├── package.json │ ├── sagui.config.js │ └── src │ ├── actionTypes.js │ ├── actions.js │ ├── components │ └── Task │ │ ├── index.jsx │ │ └── styles.css │ ├── containers │ ├── App.jsx │ └── styles.css │ ├── index.html │ ├── index.js │ ├── reducer.js │ ├── store.js │ └── subscribers │ ├── syncInput.js │ └── syncItems.js ├── package.json ├── redux-haiku.png ├── src ├── index.js └── interfaces │ ├── object-difference.js │ ├── redux.js │ └── washington.js └── test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ], 6 | "plugins": [ 7 | "transform-flow-strip-types" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.* 3 | .*/examples/.* 4 | .*/lib/.* 5 | .*/package.json 6 | 7 | [include] 8 | 9 | [libs] 10 | src/interfaces/ 11 | 12 | [options] 13 | module.system=haste 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Fernando Vía Canel 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | > A saga is long, and rambles on.
4 | > A haiku goes to the point.
5 | > Change!
6 | 7 | **redux-haiku** is yet another way of managing side-effects in Redux: but don't be disheartened, because this time it's going to be so easy and natural that when you are done you're going to be left wondering why didn't we do it this way in the first place. 8 | 9 | If you're using Redux, chances are that you are familiar with the [**Containers**](http://redux.js.org/docs/basics/UsageWithReact.html) from [`react-redux`](https://github.com/reactjs/react-redux): well, `redux-haiku` **Subscribers** work exactly like Containers, but they allow you to operate with any type of side-effect, not just DOM related ones. 10 | 11 | Be mindful though, `redux-haiku` Subscribers are a rather low level API for implementing bindings between Redux stores and diverse side-effects: it's very likely that your application should be consuming a binding library that uses `redux-haiku` internally, instead of setting up Subscribers directly in the app. Of course, this is a new implementation, and such libraries do not exist: but they might in the future, especially if you create some. If there are bindings already out there for what you want to do (say, [connect the store to Falcor](https://github.com/ekosz/redux-falcor)) you're very likely better off using that. 12 | 13 | Without further ado, take a look at how a Subscriber looks like: 14 | 15 | ```javascript 16 | // subscribers/syncNew.js 17 | import { connect, getDiff } from 'redux-haiku' 18 | import * as actions from '../actions' 19 | import { compose } from 'redux' 20 | import axios from 'axios' // Simple XHR lib 21 | 22 | const syncNew = ({ articles, onSuccess, onFailure }) => { 23 | articles.map((article) => { 24 | axios.put(`/articles/${article.key}`, { ...article }) 25 | .then(() => onSuccess(article)) 26 | .catch(() => onFailure(article)) 27 | }) 28 | } 29 | 30 | const mapStateToProps = (state, prevState) => { 31 | const getKeysSelector = (state) => 32 | state && 33 | state.articles && 34 | state.articles.map( 35 | ({ key }) => key 36 | ) 37 | 38 | const getArticleKeysDiff = getDiff(getKeysSelector) 39 | 40 | const newArticleKeys = getArticleKeysDiff(prevState, state).after 41 | 42 | return newArticleKeys && { 43 | articles: state.articles.filter( 44 | (article) => newArticleKeys.find((key) => key === article.key) 45 | ) 46 | } 47 | } 48 | 49 | const mapDispatchToProps = (dispatch) => ({ 50 | onSuccess: compose(dispatch, actions.saveSuccess), 51 | onFailure: compose(dispatch, actions.saveFailure) 52 | }) 53 | 54 | export default connect(mapStateToProps, mapDispatchToProps)(syncNew) 55 | ``` 56 | 57 | Looks familiar, right? That's exactly the point. What `redux-haiku` proposes is that any side-effect can be treated just like a DOM side-effect–that is, it can be done as the result of a state change. The state change can be identified by running a diff between the new and the old states on the segment of the state that the side-effect cares about, in the meanwhile reusing established patterns such as selectors, `mapStateToProps`, `mapDispatchToProps`, etc. 58 | 59 | Now, the "aha" moment of Redux plus React is time traveling: that's when we all realized that Redux proved its metal. Time traveling is a simple litmus test for identifying an architecture that makes for immutable, declarative apps. Time traveling for any kind of side-effects is what `redux-haiku` promises–with the only limitation that the side-effect operations need to be idempotent ones. 60 | 61 | Since we are indeed using `PUT`, an [idempotent REST verb](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.6), let's assume that's the case. We can then go full circle with another subscriber, this time for synchronizing the removal of articles: 62 | 63 | ```javascript 64 | // subscribers/syncDelete.js 65 | import { connect, getDiff } from 'redux-haiku' 66 | import * as actions from '../actions' 67 | import { compose } from 'redux' 68 | import axios from 'axios' // Simple XHR lib 69 | 70 | const syncDelete = ({ articles, onSuccess, onFailure }) => { 71 | articles.map((article) => { 72 | axios.delete(`/articles/${article.key}`) 73 | .then(() => onSuccess(article)) 74 | .catch(() => onFailure(article)) 75 | }) 76 | } 77 | 78 | const mapStateToProps = (state, prevState) => { 79 | const getKeysSelector = (state) => 80 | state && 81 | state.articles && 82 | state.articles.map( 83 | ({ key }) => key 84 | ) 85 | 86 | const getArticleKeysDiff = getDiff(getKeysSelector) 87 | 88 | const removedArticleKeys = getArticleKeysDiff(prevState, state).before 89 | 90 | return removedArticleKeys && { 91 | articles: prevState.articles.filter( 92 | (article) => removedArticleKeys.find((key) => key === article.key) 93 | ) 94 | } 95 | } 96 | 97 | const mapDispatchToProps = (dispatch) => ({ 98 | onSuccess: compose(dispatch, actions.deleteSuccess), 99 | onFailure: compose(dispatch, actions.deleteFailure) 100 | }) 101 | 102 | export default connect(mapStateToProps, mapDispatchToProps)(syncDelete) 103 | ``` 104 | 105 | `redux-haiku` will make sure that the Subscriber functions (`syncNew`, `syncDelete`) _don't even get called_ if the `mapStateToProps` returns `undefined`. That, plus the fact that the state diff is done in a small fraction of the state, more or less guarantees that the resulting implementation will remain performant. 106 | 107 | You can try it yourself in the `example` (TODO. Also provide some sort of live example with localStorage as side-effect instead of REST so it can be run completely local in the browser). 108 | 109 | Finally, the integration as it is done in the store setup: 110 | 111 | ```javascript 112 | import { createStore } from 'redux' 113 | import reducer from './reducer' 114 | import syncNew from './subscribers/syncNew' 115 | import syncDelete from './subscribers/syncDelete' 116 | 117 | const store = createStore(reducer, {}) 118 | syncNew(store) 119 | syncDelete(store) 120 | ``` 121 | 122 | ## Non-deterministic side-effects and alternative timelines 123 | 124 | > NOTE: This is to be proven in a live example using REST operations with some sort of [chaos monkey](https://github.com/Netflix/SimianArmy/wiki/Chaos-Monkey) automatic failure. Until I provide that proof, don't take my word for the stuff in this section, is just an idea of how it will work. 125 | 126 | You might have realized already that _time traveling_ in the previous example is a little of a stretch: time travel is what we would get in the best case scenario, that is, if all operations work the same every single time. This might very well not be the case: the network connection could fail, the database might have a glitch, and then re running the so-called idempotent operations would lead to a different result. 127 | 128 | Now, that in itself doesn't mean that `redux-haiku` fails at its promise. The reality is that time traveling, exactly like in movies, can take several forms: 129 | 130 | - Fixed timeline: events happened in a certain way, and going back to the past and rewinding to the future results in the same events happening again. History is unchangeable. 131 | - Multiple universes: events happened in a certain way in our current timeline, but by going back we don't really land into the same timeline and instead we create a new one. When we start going forward from there events can diverge from the original timeline and we can get an alternate universe where everything looks slightly dissimilar yet the overall structure is the same. 132 | 133 | `redux-haiku` for non-deterministic side-effects falls under the _Multiple universes_ model. In the new timeline that we start when we go back we could get a failure when trying to create an article in the server, or a connection error when trying to delete: whatever the issue, the resulting actions triggered by the side-effects would look different. This is a shame, but we could, actually, mitigate this: we simply need to react to failures by retrying until we succeed. The point is to achieve something the server-side folks call "eventual consistency" in which when going back and replaying, the resulting application state (that is, not just the redux store state but also including side-effects) eventually achieves the same state as it was in the main timeline at that particular point in time. Granted, there might be some ripples happening in the way (and it might be completely impossible if the side-effect relied on a service that went offline) but the fact that it's even possible to recover the entire application state deriving from the data in the store points to the resilience of this architecture, not to mention the fact that the implementation remains declarative and simple to understand and predictable within the margin of trust in the necessary services for the side-effects to occur. 134 | 135 | The key concept to enable this is that of _transition states_. Transition states are application states that given enough time will collapse into permanent ones. These states are artifacts of the side-effects unpredictability, and are not to be considered final states of a timeline. In the articles example, a transition state is one in which a `DELETE` operation failed to go through and the article is flagged as _removal pending_ in the state in some way and the syncDelete could schedule retrials until the removal operation is complete. Once that operation is complete, the final state is the same that would've been if the side-effect operation were deterministic. Thus the idea of replaying generating equivalent timelines that might have some differences while unfolding, but achieve consistency over time. 136 | 137 | In other words, 138 | 139 | > All parallel timelines will converge into the same state given enough time. 140 | 141 | `redux-haiku` provides a flavor of time travel in which, as long as services don't go permanently offline, manages the rewriting of history by achieving eventual consistency between the different timelines. As non-deterministic side-effects go, the advantage of this approach should be glaring: it's resilient. Until consistency is achieved, the application will update itself and keep retrying, even if stopped completely and loaded again with the same state. 142 | 143 | ## Deterministic side-effects 144 | 145 | That said, there are many categories of deterministic side-effects that in a similar manner to the DOM, can be done without risks of inconsistency in the resulting application state. For those cases, time travel is true and real: and one can wonder, if it's possible to implement them in a way that supports time traveling and makes the binding so simple, why doing them with some other strategy at all? 146 | 147 | The most natural example is `localStorage`: 148 | 149 | ```javascript 150 | import { connect } from 'redux-haiku' 151 | 152 | const syncToLocalStorage = ({ articles }) => { 153 | localStorage.setItem('articles', JSON.stringify(articles)) 154 | } 155 | 156 | const mapStateToProps = (state, prevState) => { 157 | // To be fair, there is no even need to use a diff in this example. 158 | // Since references are kept by Redux unless something inside the 159 | // collection changed, we can simply do a hard equality comparison 160 | return state.articles !== prevState.articles && { 161 | articles: state.articles 162 | } 163 | } 164 | 165 | export default connect(mapStateToProps)(syncToLocalStorage) 166 | ``` 167 | 168 | There. 169 | 170 | ## Not for synchronous control flow 171 | 172 | A common pitfall when using Redux is how to handle operations that need to be done after several other operations happened. The temptation is to wait for a change in the state and react to that by immediately dispatching another action to reflect the fact that a new operation is being performed. 173 | 174 | _This is an anti pattern_. The reality is that any operation that can be performed as a result of the state change in a subscriber can already be done in a reducer or in a selector instead. Since `redux-haiku` makes it really easy for this antipattern to emerge, it also comes bundled with a mechanism for preventing abuse of it. For example: 175 | 176 | ```javascript 177 | // subscribers/changeToCompactViewWhenTooManyItems.js 178 | import { connect } from 'redux-haiku' 179 | import { compose } from 'redux' 180 | import * as actions from '../actions' 181 | 182 | const changeToCompactViewWhenTooManyItems = ({ items, onThresholdTrespassed }) => { 183 | if (items.length > 10) { 184 | onThresholdTrespassed(items.length) 185 | } 186 | } 187 | 188 | const mapStateToProps = (state) => state 189 | const mapDispatchToProps = (dispatch) => ({ 190 | onThresholdTrespassed: compose(dispatch, actions.changeToCompactView) 191 | }) 192 | 193 | export connect(mapStateToProps, mapDispatchToProps)(changeToCompactViewWhenTooManyItems) 194 | ``` 195 | 196 | This will result in an exception! Namely: 197 | 198 | ``` 199 | Error: dispatching synchronously in a Subscriber is forbidden. Callbacks provided to Subscribers are meant to be used by asynchronous side effects as a way to trigger actions back into the store. Operations on the store to be done as a consequence of a particular state change should be done in reducers or selectors instead. 200 | ``` 201 | 202 | ## One way data flow 203 | 204 | `redux-haiku` models the data flow in three steps, exactly like Redux: 205 | 206 | 1. An Action is dispatched, that 207 | 2. …causes the global State to be updated and then 208 | 3. …some side-effect runs 209 | 210 | This is the key principle that guides `redux-haiku`'s implementation: side-effects should happen only as a consequence of state update, not by intercepting particular actions. This way the unidirectional data flow is preserved and the architecture remains consistent and easy to grasp. 211 | 212 | ## Important: `getDiff`'s quirks and limitations 213 | 214 | Now for a quick disclaimer: 215 | 216 | > `getDiff` is not stable and it only works as expected under certain specific conditions. Its implementation is very likely to change. 217 | 218 | Unlike `connect`, which is unlikely to change much in the future, `getDiff` is an experimental part of the API that is provided partially to illustrate the fact that there is nothing arcane about React's diff'ing feature and that you and anyone you know can implement their own diff'ing if need so. It's important to note then that `getDiff` has a couple of short comings that are unlikely to be addressed in the near future, mainly because of inherent limitations of JavaScript as a programming language. 219 | 220 | Those limitations are: 221 | 222 | - Object structures sent as argument to `getDiff` need to be acyclic–that is, no circular references should be present in it. This shouldn't be a problem when using with a Redux store state. 223 | - Only JSON values are allowed. Again, not a problem when diff'ing a Redux state. 224 | - Array structures are messy: 225 | - As of today, `object-difference` requires Arrays to have all of their elements to be objects, and all of those objects to contain a `key` property with a unique key, much like [React requires components in an Array to have a key](https://facebook.github.io/react/docs/multiple-components.html#dynamic-children). Scalar items with unique values could be supported in the future, but not yet. 226 | - Changes in the order of the elements of an Array will result in an empty diff. Ordering is not considered relevant for the current diff'ing algorithm: it might be added in the future, but reporting order changes is not the same as reporting modified items, so how exactly to represent the fact that the Array order changed is still to be defined. 227 | 228 | More over, what is considered dirty and what is not might not be entirely straightforward. Let's take a look at an example to try to make things easier: 229 | 230 | #### First example: Scalar values 231 | 232 | For these, simple `===` comparison is used internally: 233 | 234 | ```javascript 235 | import { getDiff } from 'redux-haiku' 236 | 237 | // Let's get a diff'er that runs over the whole object to simplify 238 | const selectAll = (x) => x 239 | const getDiffForAll = getDiff(selectAll) 240 | 241 | getDiffForAll(0, 0) // => { before: undefined, after: undefined } 242 | getDiffForAll(0, 1) // => { before: 0, after: 1 } 243 | getDiffForAll(undefined, 'hello') // => { before: undefined, after: 'hello' } 244 | ``` 245 | 246 | #### Ramping up complexity with object property comparisons 247 | 248 | ```javascript 249 | import { getDiff } from 'redux-haiku' 250 | 251 | // Let's get a diff'er that runs over the whole object to simplify 252 | const selectAll = (x) => x 253 | const getDiffForAll = getDiff(selectAll) 254 | 255 | getDiffForAll( 256 | { value: 'initial' }, 257 | { value: 'final' } 258 | ) // => { before: { value: 'initial' }, after: { value: 'final' } } 259 | 260 | getDiffForAll( 261 | { value: 'same' }, 262 | { value: 'same', added: 1 } 263 | ) // => { before: undefined, after: { added: 1 } } 264 | 265 | getDiffForAll( 266 | { value: 'same', removed: 1 }, 267 | { value: 'same', added: 2 } 268 | ) // => { before: { removed: 1 }, after: { added: 2 } } 269 | ``` 270 | 271 | As you can see, the `before` and `after` objects only show properties that are removed, added, or modified. In the second object example, nothing was removed or modified and consequently the `before` part of the diff is empty. Even when properties have been modified, only the relevant modified–_dirty_–properties are shown in the diff result. 272 | 273 | #### Now let's go to the real tricky part, arrays 274 | 275 | Again, arrays will only be compared if each element in them is an object and has a `key` property. Otherwise `object-difference` will simply fail (for now silently, that should probably be fixed). 276 | 277 | ```javascript 278 | import { getDiff } from 'redux-haiku' 279 | 280 | // Let's get a diff'er that runs over the whole object to simplify 281 | const selectAll = (x) => x 282 | const getDiffForAll = getDiff(selectAll) 283 | 284 | getDiffForAll( 285 | [], 286 | [{ key: 'new' }] 287 | ) // => { before: undefined, after: [{ key: 'new' }] } 288 | 289 | getDiffForAll( 290 | [{ key: 'removed' }, { key: 'same' }], 291 | [{ key: 'same' }] 292 | ) // => { before: [{ key: 'removed' }], after: undefined } 293 | ``` 294 | 295 | #### Putting it all together 296 | 297 | ```javascript 298 | import { getDiff } from 'redux-haiku' 299 | 300 | // Let's get a diff'er that runs over the whole object to simplify 301 | const selectAll = (x) => x 302 | const getDiffForAll = getDiff(selectAll) 303 | 304 | getDiffForAll( 305 | { 306 | items: [ 307 | { 308 | key: 'modified', 309 | value: 'initial', 310 | kept: 'same' 311 | } 312 | ], 313 | modified: { 314 | but: 'only in part', 315 | kept: 'same' 316 | } 317 | }, 318 | 319 | { 320 | items: [ 321 | { 322 | key: 'modified', 323 | value: 'final', 324 | kept: 'same' 325 | } 326 | ], 327 | modified: { 328 | different: 'only in part', 329 | kept: 'same' 330 | } 331 | } 332 | ) 333 | /* 334 | * => { 335 | * before: { 336 | * items: [ 337 | * { 338 | * value: 'initial' 339 | * } 340 | * ], 341 | * modified: { 342 | * but: 'only in part' 343 | * } 344 | * }, 345 | * after: { 346 | * items: [ 347 | * { 348 | * value: 'final' 349 | * } 350 | * ], 351 | * modified: { 352 | * different: 'only in part' 353 | * } 354 | * } 355 | * } 356 | */ 357 | ``` 358 | 359 | Naturally, the recommendation is to run the diff only in the subset of the state that you care about: otherwise is far more likely that you are going to have to double check to find out if an object is present because it was modified/removed/added completely or if it's only there because one of its inner properties changed. It will also help with performance somewhat to run scoped diff's, since the way `redux-haiku` Subscribers are wired, the diff'ing part will be run each time there is an update in the store state. 360 | 361 | ### About `getDiff`s implementation and future 362 | 363 | `getDiff` itself is a thin wrapper around [object-difference](https://github.com/xaviervia/object-difference), an experimental JavaScript library for implementing diff'ing between object structures. `getDiff` adds support for selectors to subset the state before the diff is run and normalizes the return value of `object-difference` to be more friendly to consumers, but other than that all limitations of the diff function are limitations of the `object-difference` library itself. Please refer there for further discussion. 364 | 365 | The limitations and quirks described above are not considered to be hindrance right now, because at the end of the day `getDiff` is not a hard requirement for the Subscribers to work: it's just sugar to point out in the right direction regarding how the logic of the `mapStateToProps` should look like. Since the `mapStateToProps` in `connect` gets both the present and the previous state however, diff'ing can always be done manually and there is no specific need to rely on the `getDiff` function, which means that limitations in this function do not invalidate the idea in itself. As it's easy to see from some quirks in React, such as the need for a `key` in array structures, diff'ing is a hard problem in JavaScript, and only certain structures are diff'able. 366 | 367 | ## Installation 368 | 369 | ``` 370 | npm install --save redux-haiku 371 | ``` 372 | 373 | Requires a Redux compatible store to work. 374 | 375 | ## Testing 376 | 377 | Clone this repo and 378 | 379 | ``` 380 | npm install 381 | npm test 382 | ``` 383 | 384 | ## Contributing 385 | 386 | Right now the best possible way of contributing to this project is discussion. Go to the issues and write down your thoughts, unfiltered. The implementation to documentation ratio of the project is abysmal, and that is because there is much more to discuss regarding conventions and architecture ideas than code itself. 387 | 388 | I'm particularly curious about what people think of the diff'ing feature. Using subscribers for side-effects is a rather common way of structuring a Redux application, but I think introducing diff'ing as a pre step to any subscription is the key concept in there. 389 | 390 | That said, PR's are always welcome. Just hack ahead. 391 | 392 | ## Kudos 393 | 394 | - …to the people of [`redux-saga`](https://github.com/yelouafi/redux-saga) for a nice original idea and an impressive implementation. The tongue-in-cheek reference to sagas in the introduction haiku is meant as a tribute and to give context to the name of `redux-haiku`. Thank you folks. 395 | - …to [@pirelenito](https://github.com/pirelenito) for very useful feedback that helped in shaping this implementation. 396 | 397 | ## License 398 | 399 | Copyright 2016 Fernando Vía Canel 400 | 401 | ISC license. 402 | 403 | See [LICENSE](LICENSE) attached. 404 | -------------------------------------------------------------------------------- /examples/localStorageTodo/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /examples/localStorageTodo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/sagui/.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /examples/localStorageTodo/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | dist 4 | lib 5 | -------------------------------------------------------------------------------- /examples/localStorageTodo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "localStorage", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm run develop", 8 | "develop": "sagui develop", 9 | "build": "sagui build", 10 | "dist": "NODE_ENV=production sagui dist" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "sagui": "^4.4.1" 17 | }, 18 | "dependencies": { 19 | "react": "^15.1.0", 20 | "react-dom": "^15.1.0", 21 | "react-redux": "^4.4.5", 22 | "redux": "^3.5.2", 23 | "redux-haiku": "^0.1.4", 24 | "uuid": "^2.0.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/localStorageTodo/sagui.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sagui configuration object 3 | */ 4 | module.exports = { 5 | /** 6 | * Different application entry-points 7 | * Each page is a combination of a JavaScript file and a HTML file 8 | * 9 | * Example: 'index' -> 'index.html' and 'index.js' 10 | */ 11 | pages: ['index'], 12 | 13 | /** 14 | * List of Sagui plugins to disable 15 | */ 16 | disabledPlugins: [], 17 | 18 | /** 19 | * Webpack configuration object 20 | * see: http://webpack.github.io/docs/configuration.html 21 | * 22 | * Will ovewrite and extend the default Sagui configuration 23 | */ 24 | webpackConfig: { 25 | 26 | }, 27 | 28 | /** 29 | * Karma configuration object 30 | * see: https://karma-runner.github.io/0.13/config/configuration-file.html 31 | * 32 | * Will overwrite and extend the default Sagui configuration 33 | */ 34 | karmaConfig: { 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_TASK = 'localStorageTodo/ADD_TASK' 2 | 3 | export const LOAD_TASKS = 'localStorageTodo/LOAD_TASKS' 4 | 5 | export const REMOVE_TASK = 'localStorageTodo/REMOVE_TASK' 6 | 7 | export const USER_INPUT = 'localStorageTodo/USER_INPUT' 8 | 9 | export const SET_AS_DONE = 'localStorageTodo/SET_AS_DONE' 10 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TASK, 3 | LOAD_TASKS, 4 | REMOVE_TASK, 5 | SET_AS_DONE, 6 | USER_INPUT 7 | } from './actionTypes' 8 | import uuid from 'uuid' 9 | 10 | export const addTask = (description) => ({ 11 | type: ADD_TASK, 12 | payload: { 13 | key: uuid.v4() 14 | } 15 | }) 16 | 17 | export const loadTasks = (tasks) => ({ 18 | type: LOAD_TASKS, 19 | payload: tasks 20 | }) 21 | 22 | export const removeTask = (key) => ({ 23 | type: REMOVE_TASK, 24 | payload: { 25 | key 26 | } 27 | }) 28 | 29 | export const setAsDone = (key) => ({ 30 | type: SET_AS_DONE, 31 | payload: { 32 | key 33 | } 34 | }) 35 | 36 | export const userInput = (text) => ({ 37 | type: USER_INPUT, 38 | payload: text 39 | }) 40 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/components/Task/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.css' 3 | 4 | export default function Task ({ description, done, onDone, onRemove }) { 5 | const className = done 6 | ? styles.task + ' ' + styles.isDone 7 | : styles.task 8 | 9 | return ( 10 |
  • 11 | {description} 12 | 13 | 16 | 17 | {!done && } 20 |
  • 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/components/Task/styles.css: -------------------------------------------------------------------------------- 1 | .task { 2 | border: 1px solid #555555; 3 | border-width: 1px 0 0 0; 4 | display: block; 5 | margin: 0; 6 | padding: .4rem; 7 | position: relative; 8 | line-height: 1.2rem; 9 | } 10 | 11 | .task:first-child { 12 | border-width: 0; 13 | } 14 | 15 | .task.isDone { 16 | font-style: italic; 17 | color: #999999; 18 | } 19 | 20 | .remove, .done { 21 | border: 1px solid #555555; 22 | background: white; 23 | cursor: pointer; 24 | display: none; 25 | float: right; 26 | font-family: Lato, sans-serif; 27 | font-size: .6rem; 28 | height: 1.2rem; 29 | } 30 | 31 | .remove { 32 | margin-right: -.4rem; 33 | } 34 | 35 | .task:hover .remove { 36 | display: block; 37 | } 38 | 39 | .task:hover .remove:hover { 40 | border-color: red; 41 | color: red; 42 | } 43 | 44 | .task:hover .remove:active, 45 | .task:hover .remove:focus { 46 | background: red; 47 | color: white; 48 | } 49 | 50 | .done { 51 | margin-right: .4rem; 52 | } 53 | 54 | .task:hover .done { 55 | display: block; 56 | } 57 | 58 | .task:hover .done:hover { 59 | border-color: green; 60 | color: green; 61 | } 62 | 63 | .task:hover .done:active, 64 | .task:hover .done:focus { 65 | background: green; 66 | color: white; 67 | } 68 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/containers/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { compose } from 'redux' 4 | import * as actions from '../actions' 5 | import styles from './styles.css' 6 | import Task from '../components/Task' 7 | 8 | function App ({ input, tasks, onAdd, onDone, onInput, onRemove }) { 9 | return ( 10 |
    11 |
    12 | onInput(e.target.value)} 19 | /> 20 | 21 | 26 |
    27 | 28 | 38 |
    39 | ) 40 | } 41 | 42 | const handleSubmit = (onAdd) => (e) => { 43 | e.preventDefault() 44 | 45 | onAdd() 46 | } 47 | 48 | const mapStateToProps = (state) => state 49 | const mapDispatchToProps = (dispatch) => ({ 50 | onAdd: compose(dispatch, actions.addTask), 51 | onInput: compose(dispatch, actions.userInput), 52 | onRemove: compose(dispatch, actions.removeTask), 53 | onDone: compose(dispatch, actions.setAsDone) 54 | }) 55 | 56 | export default connect( 57 | mapStateToProps, 58 | mapDispatchToProps 59 | )(App) 60 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/containers/styles.css: -------------------------------------------------------------------------------- 1 | .input, .button { 2 | border: 1px solid #333333; 3 | font-family: Lato, sans-serif; 4 | font-size: 1rem; 5 | line-height: 1.2rem; 6 | padding: .4rem; 7 | margin: 0; 8 | } 9 | 10 | .input { 11 | width: calc(100% - 6rem); 12 | } 13 | 14 | .button { 15 | background: #333333; 16 | color: white; 17 | cursor: pointer; 18 | width: 6rem; 19 | } 20 | 21 | .button:hover { 22 | background: #222222; 23 | } 24 | 25 | .button:focus, .button:active { 26 | background: black; 27 | } 28 | 29 | .list { 30 | margin: 0; 31 | padding: 0; 32 | list-style: none; 33 | } 34 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | localStorage Todo Demo App | Redux Haiku 4 | 5 | 6 | 29 | 30 | 31 |
    32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/index.js: -------------------------------------------------------------------------------- 1 | /* global localStorage */ 2 | import React from 'react' 3 | import configureStore from './store' 4 | import { render } from 'react-dom' 5 | import { Provider } from 'react-redux' 6 | import App from './containers/App' 7 | import { loadTasks, userInput } from './actions' 8 | import uuid from 'uuid' 9 | 10 | const store = configureStore() 11 | 12 | render( 13 | 14 | 15 | , 16 | document.getElementById('localStorageTodo') 17 | ) 18 | 19 | if (localStorage.getItem('localStorageTodo.tasks')) { 20 | store.dispatch( 21 | loadTasks( 22 | JSON.parse(localStorage.getItem('localStorageTodo.tasks')) 23 | ) 24 | ) 25 | } else { 26 | store.dispatch( 27 | loadTasks([ 28 | { 29 | key: uuid.v4(), 30 | description: 'React to the news' 31 | }, 32 | { 33 | key: uuid.v4(), 34 | description: 'Reduce the overhead' 35 | }, 36 | { 37 | key: uuid.v4(), 38 | description: 'Graph the fastest path', 39 | done: true 40 | } 41 | ]) 42 | ) 43 | } 44 | 45 | if (localStorage.getItem('localStorageTodo.input')) { 46 | store.dispatch( 47 | userInput( 48 | localStorage.getItem('localStorageTodo.input') 49 | ) 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TASK, 3 | LOAD_TASKS, 4 | REMOVE_TASK, 5 | SET_AS_DONE, 6 | USER_INPUT 7 | } from './actionTypes' 8 | 9 | const initialState = { 10 | tasks: [], 11 | input: '' 12 | } 13 | 14 | export default (state = initialState, { type, payload }) => { 15 | switch (type) { 16 | case ADD_TASK: 17 | if (state.input.trim() !== '') { 18 | return { 19 | ...state, 20 | tasks: [ 21 | ...state.tasks, 22 | { ...payload, description: state.input } 23 | ], 24 | input: '' 25 | } 26 | } else { 27 | return state 28 | } 29 | 30 | case LOAD_TASKS: 31 | return { 32 | ...state, 33 | tasks: payload 34 | } 35 | 36 | case REMOVE_TASK: 37 | return { 38 | ...state, 39 | tasks: [ 40 | ...state.tasks.filter((task) => task.key !== payload.key) 41 | ] 42 | } 43 | 44 | case SET_AS_DONE: 45 | return { 46 | ...state, 47 | tasks: [ 48 | ...state.tasks.map( 49 | (task) => task.key === payload.key 50 | ? { ...task, done: true } 51 | : task 52 | ) 53 | ] 54 | } 55 | 56 | case USER_INPUT: 57 | return { 58 | ...state, 59 | input: payload 60 | } 61 | 62 | default: 63 | return state 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux' 2 | import syncItems from './subscribers/syncItems' 3 | import syncInput from './subscribers/syncInput' 4 | import reducer from './reducer' 5 | 6 | export default () => { 7 | const store = createStore( 8 | reducer, 9 | { tasks: [] }, 10 | window.devToolsExtension 11 | ? window.devToolsExtension() 12 | : (f) => f 13 | ) 14 | 15 | syncItems(store) 16 | syncInput(store) 17 | 18 | return store 19 | } 20 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/subscribers/syncInput.js: -------------------------------------------------------------------------------- 1 | /* global localStorage */ 2 | import { connect } from 'redux-haiku' 3 | 4 | const syncItems = ({ input }) => { 5 | localStorage.setItem('localStorageTodo.input', input) 6 | } 7 | 8 | const mapStateToProps = (state, prevState) => { 9 | return state.input !== prevState.input && { 10 | input: state.input 11 | } 12 | } 13 | 14 | export default connect(mapStateToProps)(syncItems) 15 | -------------------------------------------------------------------------------- /examples/localStorageTodo/src/subscribers/syncItems.js: -------------------------------------------------------------------------------- 1 | /* global localStorage */ 2 | import { connect } from 'redux-haiku' 3 | 4 | const syncItems = ({ tasks }) => { 5 | localStorage.setItem('localStorageTodo.tasks', JSON.stringify(tasks)) 6 | } 7 | 8 | const mapStateToProps = (state, prevState) => { 9 | return state.tasks !== prevState.tasks && { 10 | tasks: state.tasks 11 | } 12 | } 13 | 14 | export default connect(mapStateToProps)(syncItems) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-haiku", 3 | "version": "0.1.4", 4 | "description": "Redux side-effects using state diffs in subcribers", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "rm -rf lib && babel src --out-dir lib --ignore *.spec.js", 8 | "prepublish": "not-in-install && npm prune && npm test && npm run build || in-install", 9 | "test": "flow check --all && babel-node test" 10 | }, 11 | "keywords": [ 12 | "redux", 13 | "subscriptions", 14 | "subscribe", 15 | "side-effects", 16 | "object-difference", 17 | "diff" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/xaviervia/redux-haiku.git" 22 | }, 23 | "author": "Fernando Vía Canel ", 24 | "license": "ISC", 25 | "devDependencies": { 26 | "babel-cli": "^6.10.1", 27 | "babel-plugin-transform-flow-strip-types": "^6.8.0", 28 | "babel-preset-es2015": "^6.9.0", 29 | "babel-preset-stage-0": "^6.5.0", 30 | "flow-bin": "^0.27.0", 31 | "in-publish": "^2.0.0", 32 | "redux": "^3.5.2", 33 | "washington": "^0.12.0" 34 | }, 35 | "dependencies": { 36 | "object-difference": "^0.1.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /redux-haiku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xaviervia/redux-haiku/1493d0d4cf45b10fb0186749988821cd7c66876b/redux-haiku.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import objectDifference from 'object-difference' 2 | 3 | type Action = { 4 | type: string, 5 | payload: any, 6 | meta: ?any 7 | } 8 | 9 | type Selector = (state: any, prevState: any) => any 10 | 11 | export const connect = ( 12 | mapStateToProps: Selector, 13 | mapDispatchToProps: ?(dispatch: (action: Action) => void) => any 14 | ) => ( 15 | subscriber: (args: any) => void 16 | ) => (store: any) => { 17 | let prevState = store.getState() 18 | 19 | store.subscribe(() => { 20 | const nextState = store.getState() 21 | const selectedState = mapStateToProps(nextState, prevState) 22 | let dispatchAllowed = false 23 | const wrappedDispatch = (x) => { 24 | if (dispatchAllowed) { 25 | store.dispatch(x) 26 | } else { 27 | throw new Error('Dispatching synchronously in a Subscriber is forbidden. Callbacks provided to Subscribers are meant to be used by asynchronous side effects as a way to trigger actions back into the store. Operations on the store to be done as a consequence of a particular state change should be done in reducers or selectors instead.') 28 | } 29 | } 30 | setTimeout(() => dispatchAllowed = true) 31 | 32 | if (selectedState) { 33 | const boundActionCreators = mapDispatchToProps 34 | ? mapDispatchToProps(wrappedDispatch) 35 | : {} 36 | 37 | subscriber({ 38 | ...selectedState, 39 | ...boundActionCreators 40 | }) 41 | } 42 | 43 | prevState = store.getState() 44 | }) 45 | } 46 | 47 | type DiffResult = { 48 | before: any, 49 | after: any 50 | } 51 | 52 | export const getDiff = ( 53 | selector: Selector 54 | ) => ( 55 | prev: any, 56 | next: any 57 | ): DiffResult => { 58 | const diff = objectDifference(selector(prev), selector(next)) || [] 59 | 60 | return { 61 | before: diff[0], 62 | after: diff[1] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/interfaces/object-difference.js: -------------------------------------------------------------------------------- 1 | declare module 'object-difference' { 2 | declare var exports: (prev: any, next: any) => [any, any] 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/redux.js: -------------------------------------------------------------------------------- 1 | declare module 'redux' { 2 | declare function createStore(reducer:(state: any, action: any) => any): any 3 | declare function combineReducers(...reducer:any): any 4 | declare function compose(...f:any): any 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/washington.js: -------------------------------------------------------------------------------- 1 | declare module 'washington' { 2 | declare var exports: ( 3 | name: string, 4 | example?: (done: (result: any) => void) => void 5 | ) => void 6 | declare function go(): void 7 | } 8 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import example from 'washington' 2 | import { deepEqual } from 'assert' 3 | import { connect, getDiff } from './src/index' 4 | import { createStore, compose } from 'redux' 5 | import { execSync } from 'child_process' 6 | 7 | type Action = { 8 | type: string, 9 | payload: any 10 | } 11 | 12 | type Item = { 13 | key: string, 14 | saved: boolean 15 | } 16 | 17 | type State = { 18 | items: Array 19 | } 20 | 21 | example(`save an item removed after it's is added`, (done) => { 22 | /* ACTIONS */ 23 | const addItemActionCreator = (text): Action => ({ 24 | type: 'ADD_ITEM', 25 | payload: { 26 | key: text, 27 | saved: false 28 | } 29 | }) 30 | 31 | const itemSavedActionCreator = (key): Action => ({ 32 | type: 'ITEM_SAVED', 33 | payload: key 34 | }) 35 | 36 | /* EXTERNAL LIBRARY FOR A SIDE EFFECT */ 37 | const asyncSaveFunction = (item, onSaved) => { 38 | // We fake the asynchronous save request, which could for example 39 | // be calling a REST endpoint 40 | setTimeout(() => { 41 | console.log(`item('${item.key}') is saved now`) 42 | onSaved(item) 43 | }, 20) 44 | } 45 | 46 | /* REDUCER */ 47 | const initialState: State = { items: [] } 48 | 49 | const reducer = (state: State, action: Action): State => { 50 | switch (action.type) { 51 | case 'ADD_ITEM': 52 | return { 53 | items: [ ...state.items, action.payload ] 54 | } 55 | 56 | case 'ITEM_SAVED': 57 | return { 58 | items: [ 59 | ...state.items.map((item) => 60 | item.key === action.payload 61 | ? { ...item, saved: true } 62 | : item 63 | ) 64 | ] 65 | } 66 | 67 | default: 68 | return state 69 | } 70 | } 71 | 72 | /* SELECTORS */ 73 | const getItemKeysSelector = (state) => 74 | state && 75 | state.items.map(({ key }) => ({ key })) 76 | 77 | /* SUBSCRIBER *new shiny thing* 78 | * you can think about this as something like a react-redux container 79 | * for arbitrary asynchronous side effects 80 | */ 81 | const subscriber = ({ items, onSaved }) => 82 | items.map((item) => asyncSaveFunction(item, onSaved)) 83 | 84 | const mapStateToProps = (state, prevState) => { 85 | const newItemKeys = getDiff(getItemKeysSelector)(prevState, state).after 86 | 87 | return newItemKeys && { 88 | items: newItemKeys.map(({ key }) => 89 | state.items.find((item) => item.key === key) 90 | ) 91 | } 92 | } 93 | 94 | const mapDispatchToProps = (dispatch, getState) => ({ 95 | onSaved: (item) => 96 | compose(dispatch, itemSavedActionCreator)(item.key) 97 | }) 98 | 99 | const connectedSubscriber = connect( 100 | mapStateToProps, 101 | mapDispatchToProps 102 | )(subscriber) 103 | 104 | /* STORE */ 105 | const store = createStore(reducer, initialState) 106 | 107 | // Connect the subscriber 108 | connectedSubscriber(store) 109 | 110 | // Start the thing by creating two items 111 | store.dispatch(addItemActionCreator('hello world of subscribers')) 112 | store.dispatch(addItemActionCreator('hola mundo de los subscribers')) 113 | 114 | /* ASSERTION */ 115 | // Let's wait a couple of milliseconds for the asynchronous operations to 116 | // complete 117 | setTimeout(() => { 118 | deepEqual( 119 | store.getState().items, 120 | [ 121 | { 122 | key: 'hello world of subscribers', 123 | saved: true 124 | }, 125 | 126 | { 127 | key: 'hola mundo de los subscribers', 128 | saved: true 129 | } 130 | ] 131 | ) 132 | 133 | done() 134 | }, 30) 135 | }) 136 | 137 | example(`it will show an error if you try to dispatch synchronously`, (done) => { 138 | const addItem = (key) => ({ 139 | type: 'ADD_ITEM', 140 | payload: { key } 141 | }) 142 | 143 | const compactItems = () => ({ 144 | type: 'COMPACT_ITEMS' 145 | }) 146 | 147 | const initialState = { items: [], compacted: false } 148 | 149 | const reducer = (state, action) => { 150 | switch (action.type) { 151 | case 'ADD_ITEM': 152 | return { ...state, items: [ ...state.items, action.payload ] } 153 | 154 | case 'COMPACT_ITEMS': 155 | return { ...state, compected: true } 156 | 157 | default: 158 | return state 159 | } 160 | } 161 | 162 | const store = createStore(reducer, initialState) 163 | 164 | const faultySubscriber = ({ items, onThirdItem }) => { 165 | if (items.length > 2) { 166 | onThirdItem() 167 | } 168 | } 169 | const mapStateToProps = (state) => state 170 | const mapDispatchToProps = (dispatch) => ({ 171 | onThirdItem: compose(dispatch, compactItems) 172 | }) 173 | 174 | const connectedFaultySubscriber = connect(mapStateToProps, mapDispatchToProps)(faultySubscriber) 175 | 176 | connectedFaultySubscriber(store) 177 | 178 | store.dispatch(addItem('first')) 179 | store.dispatch(addItem('second')) 180 | 181 | try { 182 | store.dispatch(addItem('third')) 183 | } catch (e) { 184 | done(e.message, `Dispatching synchronously in a Subscriber is forbidden. Callbacks provided to Subscribers are meant to be used by asynchronous side effects as a way to trigger actions back into the store. Operations on the store to be done as a consequence of a particular state change should be done in reducers or selectors instead.`) 185 | } 186 | }) 187 | 188 | example.go() 189 | --------------------------------------------------------------------------------