Skip to content

Commit

Permalink
Merge pull request #12 from salsita/next
Browse files Browse the repository at this point in the history
v1.0.0
  • Loading branch information
tomkis committed Mar 24, 2016
2 parents 3b0b546 + 5f69adb commit ec09573
Show file tree
Hide file tree
Showing 23 changed files with 511 additions and 276 deletions.
4 changes: 1 addition & 3 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,8 @@
"before": false,
"after": true
}],
"space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords
"space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks
"space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren
"space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops
"space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case
"space-infix-ops": 2 // http://eslint.org/docs/rules/space-infix-ops
}
}
84 changes: 77 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ Some people (I am one of them) believe that [Elm](/~https://github.com/evancz/elm-

1) Application logic is not in one place, which leads to the state where business domain may be encapsulated by service domain.

2) Unit testing of some use cases which heavy relies on side effect is nearly impossible. Yes, you can always test those things in isolation but then you will lose the context. It's breaking the logic apart, which is making it basically impossible to test.
2) Unit testing of some use cases which heavy relies on side effect is nearly impossible. Yes, you can always test those things in isolation but then you will lose the context. It's breaking the logic apart, which is making it basically impossible to test as single unit.

Therefore ideal solution is to keep the domain logic where it belongs (reducers) and abstract away execution of side effects. Which means that your reducers will still be pure (Yes! Also hot-reloadable and easily testable). There are basically two options, either we can abuse reducer's reduction (which is basically a form of I/O Monad) or we can simply put a bit more syntactic sugar on it.

Because ES6 [generators](https://developer.mozilla.org/cs/docs/Web/JavaScript/Reference/Statements/function*) is an excellent way how to perform lazy evaluation, it's also a perfect tool for the syntax sugar to simplify working with side effects.

Just imagine, you can `yield` a function which is not executed in the reducer itself but the execution is simply deferred.
Just imagine, you can `yield` a side effect and framework runtime is responsible for executing it after `reducer` reduces new application state. This ensures that Reducer remains pure.

```javascript
const sideEffect = message => () => console.log(message);
import { sideEffect } from 'redux-side-effects';

const loggingEffect = (dispatch, messaage) => console.log(message);

function* reducer(appState = 1, action) {
yield sideEffect('This is side effect');
yield sideEffect(loggingEffect, 'This is side effect');

return appState + 1;
}
Expand All @@ -39,7 +41,7 @@ The function is technically pure because it does not execute any side effects an

## Usage

API of this library is fairly simple, the only possible function is `createEffectCapableStore`. In order to use it in your application you need to import it, keep in mind that it's [named import](http://www.2ality.com/2014/09/es6-modules-final.html#named_exports_%28several_per_module%29) therefore following construct is correct:
API of this library is fairly simple, the only possible functions are `createEffectCapableStore` and `sideEffect`. `createEffectCapableStore` is a store enhancer which enables us to use Reducers in form of Generators. `sideEffect` returns declarative Side effect and allows us easy testing. In order to use it in your application you need to import it, keep in mind that it's [named import](http://www.2ality.com/2014/09/es6-modules-final.html#named_exports_%28several_per_module%29) therefore following construct is correct:

`import { createEffectCapableStore } from 'redux-side-effects'`

Expand All @@ -64,15 +66,17 @@ Basically something like this should be fully functional:
import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { createEffectCapableStore } from 'redux-side-effects';
import { createEffectCapableStore, sideEffect } from 'redux-side-effects';

import * as API from './API';

const storeFactory = createEffectCapableStore(createStore);

const addTodoEffect = (dispatch, todo) => API.addTodo(todo).then(() => dispatch({type: 'TODO_ADDED'});

const store = storeFactory(function*(appState = {todos: [], loading: false}, action) {
if (action.type === 'ADD_TODO') {
yield dispatch => API.addTodo(action.payload).then(() => dispatch({type: 'TODO_ADDED'}))
yield sideEffect(addTodoEffect, action.payload);

return {...appState, todos: [...appState.todos, action.payload], loading: true};
} else if (action.type === 'TODO_ADDED') {
Expand All @@ -86,6 +90,72 @@ render(<Application store={store} />, document.getElementById('app-container'));

```
## Declarative Side Effects
The `sideEffect` function is just a very simple declarative way how to express any Side Effect. Basically you can only `yield` result of the function and the function must be called with at least one argument which is Side Effect execution implementation function, all the additional arguments will be passed as arguments to your Side Effect execution implementation function.
```javascript
const effectImplementation = (dispatch, arg1, arg2, arg3) => {
// Your Side Effect implementation
};


yield sideEffect(effectImplementation, 'arg1', 'arg2', 'arg3'....);
```
Be aware that first argument provided to Side Effect implementation function is always `dispatch` so that you can `dispatch` new actions within Side Effect.
## Unit testing
Unit Testing with `redux-side-effects` is a breeze. You just need to assert iterable which is result of Reducer.
```javascript
function* reducer(appState) {
if (appState === 42) {
yield sideEffect(fooEffect, 'arg1');

return 1;
} else {
return 0;
}
}

// Now we can effectively assert whether app state is correctly mutated and side effect is yielded.
it('should yield fooEffect with arg1 when condition is met', () => {
const iterable = reducer(42);

assert.deepEqual(iterable.next(), {
done: false,
value: sideEffect(fooEffect, 'arg1')
});
assert.equal(iterable.next(), {
done: true,
value: 1
});
})

```
## Example
There's very simple fully working example including unit tests inside `example` folder.
You can check it out by:
```
cd example
npm install
npm start
open http://localhost:3000
```
Or you can run tests by
```
cd example
npm install
npm test
```
## Contribution
In case you are interested in contribution, feel free to send a PR. Keep in mind that any created issue is much appreciated. For local development:
Expand Down
3 changes: 3 additions & 0 deletions example/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": ["es2015", "stage-2", "react"]
}
15 changes: 15 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# redux-side-effects-example

Run example:

```
npm install
npm start
open http://localhost:3000
```

Run tests:

```
npm test
```
11 changes: 11 additions & 0 deletions example/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>redux-side-effects-example</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/app.bundle.js"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions example/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "redux-side-effects-example",
"version": "0.1.0",
"scripts": {
"start": "./node_modules/.bin/webpack-dev-server --config webpack.config.js --port 3000 --hot --content-base ./",
"test": "./node_modules/.bin/mocha --require babel-core/register --recursive --require babel-polyfill",
"test:watch": "npm test -- --watch"
},
"devDependencies": {
"babel-cli": "^6.5.1",
"babel-core": "^6.5.2",
"babel-eslint": "^4.1.8",
"babel-loader": "^6.2.2",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
"babel-preset-stage-2": "^6.5.0",
"chai": "^3.4.1",
"mocha": "^2.2.5",
"webpack": "^1.12.4",
"webpack-dev-server": "^1.12.1"
},
"dependencies": {
"babel-runtime": "^6.5.0",
"bluebird": "^3.3.4",
"react": "^0.14.7",
"react-dom": "^0.14.2",
"react-redux": "^4.0.0",
"react-router": "^2.0.0",
"react-router-redux": "^4.0.0",
"redux": "^3.3.1",
"redux-elm": "^1.0.0-alpha",
"redux-side-effects": "^1.0.0",
"superagent": "^1.8.2",
"superagent-bluebird-promise": "^3.0.0"
},
"author": "Tomas Weiss <tomasw@salsitasoft.com>",
"license": "MIT"
}
17 changes: 17 additions & 0 deletions example/src/GifViewer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { connect } from 'react-redux';

export default connect(appState => appState)(({ gifUrl, topic, loading, dispatch }) => {
if (!loading) {
return (
<div>
{gifUrl && <img src={gifUrl} width={200} height={200} />}
<br />
Topic: <input type="text" onChange={ev => dispatch({ type: 'CHANGE_TOPIC', payload: ev.target.value })} value={topic} /><br />
<button onClick={() => dispatch({ type: 'LOAD_GIF' })}>Load GIF!</button>
</div>
);
} else {
return <div>Loading</div>;
}
});
21 changes: 21 additions & 0 deletions example/src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { render } from 'react-dom';
import { createStore, compose } from 'redux';
import { Provider } from 'react-redux';
import { createEffectCapableStore } from 'redux-side-effects';

import reducer from './reducer';
import GifViewer from './GifViewer';

const storeFactory = compose(
createEffectCapableStore,
window.devToolsExtension ? window.devToolsExtension() : f => f
)(createStore);

const store = storeFactory(reducer);

render((
<Provider store={store}>
<GifViewer />
</Provider>
), document.getElementById('app'));
40 changes: 40 additions & 0 deletions example/src/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import request from 'superagent-bluebird-promise';
import { sideEffect } from 'redux-side-effects';

export const loadGifEffect = (dispatch, topic) => {
request(`http://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=${topic}`)
.then(response => dispatch({ type: 'NEW_GIF', payload: response.body.data.image_url }));
};

const initialAppState = {
gifUrl: null,
loading: false,
topic: 'funny cats'
};

export default function* (appState = initialAppState, action) {
switch (action.type) {
case 'CHANGE_TOPIC':
return {
...appState,
topic: action.payload
};

case 'LOAD_GIF':
yield sideEffect(loadGifEffect, appState.topic);

return {
...appState,
loading: true
};

case 'NEW_GIF':
return {
...appState,
loading: false,
gifUrl: action.payload
};
default:
return appState;
}
};
41 changes: 41 additions & 0 deletions example/test/reducer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { assert } from 'chai';
import { sideEffect } from 'redux-side-effects';

import reducer, { loadGifEffect } from '../src/reducer';

describe('reducer test', () => {
it('should set loading flag and yield side effect to load gif with specific topic after clicking the button', () => {
const initialAppState = reducer(undefined, { type: 'init' }).next().value;
const iterable = reducer(initialAppState, { type: 'LOAD_GIF' });

assert.deepEqual(iterable.next(), {
done: false,
value: sideEffect(loadGifEffect, 'funny cats')
});
assert.deepEqual(iterable.next(), {
done: true,
value: {
gifUrl: null,
loading: true,
topic: 'funny cats'
}
});
});

it('should reset the loading flag and set appropriate GIF url when GIF is loaded', () => {
const iterable = reducer({
gifUrl: null,
loading: true,
topic: 'funny cats'
}, { type: 'NEW_GIF', payload: 'newurl' });

assert.deepEqual(iterable.next(), {
done: true,
value: {
gifUrl: 'newurl',
loading: false,
topic: 'funny cats'
}
});
});
});
31 changes: 31 additions & 0 deletions example/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
var path = require('path');
var webpack = require('webpack');

module.exports = {
debug: true,
target: 'web',
devtool: 'sourcemap',
plugins: [
new webpack.NoErrorsPlugin()
],
entry: [
'babel-polyfill',
'webpack-dev-server/client?http://localhost:3000',
'webpack/hot/only-dev-server',
'./src/main.js'
],
output: {
path: path.join(__dirname, './dev'),
filename: 'app.bundle.js'
},
module: {
loaders: [{
test: /\.jsx$|\.js$/,
loaders: ['babel-loader'],
include: path.join(__dirname, './src')
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
}
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"babel-preset-stage-0": "^6.3.13",
"babel-runtime": "^6.3.19",
"chai": "^3.4.1",
"eslint": "^1.9.0",
"eslint": "^2.4.0",
"estraverse-fb": "^1.3.1",
"isparta": "^4.0.0",
"mocha": "^2.2.5",
"redux": "^3.0.4",
Expand Down
Loading

0 comments on commit ec09573

Please sign in to comment.