├── .babelrc ├── .gitignore ├── examples ├── auth │ ├── .babelrc │ ├── dev │ │ └── index.html │ ├── src │ │ ├── constants │ │ │ └── actions.js │ │ ├── main.js │ │ ├── actions │ │ │ └── actionCreators.js │ │ ├── reducers │ │ │ └── rootReducer.js │ │ ├── components │ │ │ └── Application.jsx │ │ └── sagas │ │ │ └── authSaga.js │ ├── webpack.config.js │ └── package.json ├── real-world │ ├── .babelrc │ ├── src │ │ ├── containers │ │ │ ├── Root.js │ │ │ ├── DevTools.js │ │ │ ├── Root.prod.js │ │ │ ├── Root.dev.js │ │ │ ├── App.js │ │ │ ├── UserPage.js │ │ │ └── RepoPage.js │ │ ├── store │ │ │ ├── configureStore.js │ │ │ ├── configureStore.prod.js │ │ │ └── configureStore.dev.js │ │ ├── routes.js │ │ ├── components │ │ │ ├── User.jsx │ │ │ ├── Repo.jsx │ │ │ ├── List.jsx │ │ │ └── Explore.jsx │ │ ├── main.js │ │ ├── actions │ │ │ └── index.js │ │ ├── reducers │ │ │ ├── index.js │ │ │ └── paginate.js │ │ └── sagas │ │ │ ├── api.js │ │ │ └── realWorldSaga.js │ ├── dev │ │ └── index.html │ ├── webpack.config.js │ └── package.json └── undo-redo-optimistic │ ├── .babelrc │ ├── dev │ └── index.html │ ├── src │ ├── constants │ │ └── actions.js │ ├── main.js │ ├── actions │ │ └── actionCreators.js │ ├── components │ │ └── Application.jsx │ ├── reducers │ │ └── rootReducer.js │ └── sagas │ │ └── commandSaga.js │ ├── webpack.config.js │ └── package.json ├── docs ├── atm_1.png └── atm_2.png ├── .travis.yml ├── src └── index.js ├── package.json ├── test └── sagaMiddleware.test.js ├── README.md └── .eslintrc /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | npm-debug.log -------------------------------------------------------------------------------- /examples/auth/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } -------------------------------------------------------------------------------- /examples/real-world/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } -------------------------------------------------------------------------------- /docs/atm_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salsita/redux-saga-rxjs/HEAD/docs/atm_1.png -------------------------------------------------------------------------------- /docs/atm_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salsita/redux-saga-rxjs/HEAD/docs/atm_2.png -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | script: 5 | - npm run lint 6 | - npm test -------------------------------------------------------------------------------- /examples/real-world/src/containers/Root.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./Root.prod'); 3 | } else { 4 | module.exports = require('./Root.dev'); 5 | } 6 | -------------------------------------------------------------------------------- /examples/real-world/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./configureStore.prod'); 3 | } else { 4 | module.exports = require('./configureStore.dev'); 5 | } 6 | -------------------------------------------------------------------------------- /examples/auth/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | redux-saga-rxjs 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/real-world/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | redux-saga-rxjs 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | redux-saga-rxjs 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/auth/src/constants/actions.js: -------------------------------------------------------------------------------- 1 | // User actions 2 | export const LOG_IN = 'LOG_IN'; 3 | export const LOG_OUT = 'LOG_OUT'; 4 | export const CHANGE_CREDENTIALS = 'CHANGE_CREDENTIALS'; 5 | 6 | // Saga actions 7 | export const TOKEN_REFRESHED = 'TOKEN_REFRESHED'; 8 | export const HIDE_TOAST = 'HIDE_TOAST'; 9 | 10 | // API callbacks 11 | export const LOGGED_IN = 'LOGGED_IN'; 12 | export const LOG_IN_FAILURE = 'LOG_IN_FAILURE'; 13 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/DevTools.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDevTools } from 'redux-devtools'; 3 | import LogMonitor from 'redux-devtools-log-monitor'; 4 | import DockMonitor from 'redux-devtools-dock-monitor'; 5 | 6 | export default createDevTools( 7 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /examples/real-world/src/store/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import sagaMiddleware from 'redux-saga-rxjs'; 3 | import rootReducer from '../reducers'; 4 | import realWorldSaga from '../sagas/realWorldSaga'; 5 | 6 | export default function configureStore(initialState) { 7 | return createStore( 8 | rootReducer, 9 | initialState, 10 | applyMiddleware(sagaMiddleware(realWorldSaga)) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/real-world/src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router'; 3 | import App from './containers/App'; 4 | import UserPage from './containers/UserPage'; 5 | import RepoPage from './containers/RepoPage'; 6 | 7 | export default ( 8 | 9 | 11 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /examples/real-world/src/components/User.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default ({ user }) => { 5 | const { login, avatarUrl, name } = user; 6 | 7 | return ( 8 |
9 | 10 | 11 |

12 | {login} {name && ({name})} 13 |

14 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /examples/real-world/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { hashHistory } from 'react-router'; 4 | import { syncHistoryWithStore } from 'react-router-redux'; 5 | 6 | import configureStore from './store/configureStore'; 7 | import Root from './containers/Root'; 8 | 9 | const store = configureStore(); 10 | const history = syncHistoryWithStore(hashHistory, store); 11 | 12 | render(, document.getElementById('app')); 13 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/Root.prod.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import routes from '../routes'; 4 | import { Router } from 'react-router'; 5 | 6 | export default class Root extends Component { 7 | render() { 8 | const { store, history } = this.props; 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | 17 | Root.propTypes = { 18 | store: PropTypes.object.isRequired 19 | }; 20 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/constants/actions.js: -------------------------------------------------------------------------------- 1 | // User actions 2 | export const CHANGE_TEXT = 'CHANGE_TEXT'; 3 | export const ADD_TODO = 'ADD_TODO'; 4 | export const UNDO = 'UNDO'; 5 | export const REDO = 'REDO'; 6 | 7 | // Saga actions 8 | export const ADD_TODO_ID = 'ADD_TODO_ID'; 9 | export const CLEAR_REDO_LOG = 'CLEAR_REDO_LOG'; 10 | 11 | // API callbacks 12 | export const TODO_ADDED = 'TODO_ADDED'; 13 | export const ADD_TODO_FAILED = 'ADD_TODO_FAILED'; 14 | export const UNDONE = 'UNDONE'; 15 | export const UNDO_FAILED = 'UNDO_FAILED'; 16 | export const REDONE = 'REDONE'; 17 | -------------------------------------------------------------------------------- /examples/real-world/src/components/Repo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default ({ owner, repo }) => { 5 | const { login } = owner; 6 | const { name, description } = repo; 7 | 8 | return ( 9 |
10 |

11 | 12 | {name} 13 | 14 | {' by '} 15 | 16 | {login} 17 | 18 |

19 | {description && 20 |

{description}

21 | } 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /examples/auth/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import sagaMiddleware from 'redux-saga-rxjs'; 6 | 7 | import Application from './components/Application'; 8 | import rootReducer from './reducers/rootReducer'; 9 | import authSaga from './sagas/authSaga'; 10 | 11 | const store = createStore(rootReducer, undefined, applyMiddleware(sagaMiddleware(authSaga))); 12 | 13 | render(( 14 | 15 | 16 | 17 | ), document.getElementById('app')); 18 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import sagaMiddleware from 'redux-saga-rxjs'; 6 | 7 | import Application from './components/Application'; 8 | import rootReducer from './reducers/rootReducer'; 9 | import commandSaga from './sagas/commandSaga'; 10 | 11 | const store = createStore(rootReducer, undefined, applyMiddleware(sagaMiddleware(commandSaga))); 12 | 13 | render(( 14 | 15 | 16 | 17 | ), document.getElementById('app')); 18 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/Root.dev.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import routes from '../routes'; 4 | import DevTools from './DevTools'; 5 | import { Router } from 'react-router'; 6 | 7 | export default class Root extends Component { 8 | render() { 9 | const { store, history } = this.props; 10 | return ( 11 | 12 |
13 | 14 | 15 |
16 |
17 | ); 18 | } 19 | } 20 | 21 | Root.propTypes = { 22 | store: PropTypes.object.isRequired 23 | }; 24 | -------------------------------------------------------------------------------- /examples/auth/src/actions/actionCreators.js: -------------------------------------------------------------------------------- 1 | import * as Actions from '../constants/actions'; 2 | 3 | export const logIn = credentials => ({type: Actions.LOG_IN, payload: credentials}); 4 | export const logOut = () => ({type: Actions.LOG_OUT, payload: null}); 5 | export const changeCredentials = credentials => ({type: Actions.CHANGE_CREDENTIALS, payload: credentials}); 6 | export const tokenRefreshed = refreshed => ({type: Actions.TOKEN_REFRESHED, payload: refreshed}); 7 | export const hideToast = () => ({type: Actions.HIDE_TOAST, payload: null}); 8 | export const loggedIn = (credentials, refreshed) => ({type: Actions.LOGGED_IN, payload: { credentials, refreshed }}); 9 | export const logInFailure = () => ({type: Actions.LOG_IN_FAILURE, payload: null}); 10 | -------------------------------------------------------------------------------- /examples/auth/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | debug: true, 6 | target: 'web', 7 | devtool: 'sourcemap', 8 | plugins: [ 9 | new webpack.NoErrorsPlugin() 10 | ], 11 | entry: [ 12 | 'webpack-dev-server/client?http://localhost:3000', 13 | 'webpack/hot/only-dev-server', 14 | './src/main.js' 15 | ], 16 | output: { 17 | path: path.join(__dirname, './dev'), 18 | filename: 'app.bundle.js' 19 | }, 20 | module: { 21 | loaders: [{ 22 | test: /\.jsx$|\.js$/, 23 | loaders: ['babel-loader'], 24 | include: path.join(__dirname, './src') 25 | }] 26 | }, 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /examples/real-world/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | debug: true, 6 | target: 'web', 7 | devtool: 'sourcemap', 8 | plugins: [ 9 | new webpack.NoErrorsPlugin() 10 | ], 11 | entry: [ 12 | 'webpack-dev-server/client?http://localhost:3000', 13 | 'webpack/hot/only-dev-server', 14 | './src/main.js' 15 | ], 16 | output: { 17 | path: path.join(__dirname, './dev'), 18 | filename: 'app.bundle.js' 19 | }, 20 | module: { 21 | loaders: [{ 22 | test: /\.jsx$|\.js$/, 23 | loaders: ['babel-loader'], 24 | include: path.join(__dirname, './src') 25 | }] 26 | }, 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | debug: true, 6 | target: 'web', 7 | devtool: 'sourcemap', 8 | plugins: [ 9 | new webpack.NoErrorsPlugin() 10 | ], 11 | entry: [ 12 | 'webpack-dev-server/client?http://localhost:3000', 13 | 'webpack/hot/only-dev-server', 14 | './src/main.js' 15 | ], 16 | output: { 17 | path: path.join(__dirname, './dev'), 18 | filename: 'app.bundle.js' 19 | }, 20 | module: { 21 | loaders: [{ 22 | test: /\.jsx$|\.js$/, 23 | loaders: ['babel-loader'], 24 | include: path.join(__dirname, './src') 25 | }] 26 | }, 27 | resolve: { 28 | extensions: ['', '.js', '.jsx'] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /examples/real-world/src/components/List.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const renderLoadMore = (isFetching, onLoadMoreClick) => ( 4 | 9 | ); 10 | 11 | export default ({ isFetching, nextPageUrl, pageCount, items, renderItem, loadingLabel, onLoadMoreClick }) => { 12 | const isEmpty = items.length === 0; 13 | if (isEmpty && isFetching) { 14 | return

{loadingLabel}

; 15 | } 16 | 17 | const isLastPage = !nextPageUrl; 18 | if (isEmpty && isLastPage) { 19 | return

Nothing here!

; 20 | } 21 | 22 | return ( 23 |
24 | {items.map(renderItem)} 25 | {pageCount > 0 && !isLastPage && renderLoadMore(isFetching, onLoadMoreClick)} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /examples/real-world/src/store/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import createLogger from 'redux-logger'; 3 | import sagaMiddleware from 'redux-saga-rxjs'; 4 | import rootReducer from '../reducers'; 5 | import DevTools from '../containers/DevTools'; 6 | import realWorldSaga from '../sagas/realWorldSaga'; 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStore( 10 | rootReducer, 11 | initialState, 12 | compose( 13 | applyMiddleware(createLogger(), sagaMiddleware(realWorldSaga)), 14 | DevTools.instrument() 15 | ) 16 | ); 17 | 18 | if (module.hot) { 19 | // Enable Webpack hot module replacement for reducers 20 | module.hot.accept('../reducers', () => { 21 | const nextRootReducer = require('../reducers').default; 22 | store.replaceReducer(nextRootReducer); 23 | }); 24 | } 25 | 26 | return store; 27 | } 28 | -------------------------------------------------------------------------------- /examples/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-rxjs-example-auth", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "./node_modules/.bin/webpack-dev-server --config webpack.config.js --port 3000 --hot --content-base ./dev" 6 | }, 7 | "devDependencies": { 8 | "babel-cli": "^6.5.1", 9 | "babel-core": "^6.5.2", 10 | "babel-eslint": "^4.1.8", 11 | "babel-loader": "^6.2.2", 12 | "babel-preset-es2015": "^6.5.0", 13 | "babel-preset-react": "^6.5.0", 14 | "babel-preset-stage-2": "^6.5.0", 15 | "webpack": "^1.12.4", 16 | "webpack-dev-server": "^1.12.1" 17 | }, 18 | "dependencies": { 19 | "babel-runtime": "^6.5.0", 20 | "moment": "^2.11.2", 21 | "react": "^0.14.2", 22 | "react-dom": "^0.14.2", 23 | "react-redux": "^4.0.0", 24 | "redux": "^3.0.4", 25 | "redux-saga-rxjs": "^0.2.0", 26 | "rxjs": "^5.0.0-beta.2" 27 | }, 28 | "author": "Tomas Weiss ", 29 | "license": "MIT" 30 | } 31 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-rxjs-example-undo-redo-optimistic", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "./node_modules/.bin/webpack-dev-server --config webpack.config.js --port 3000 --hot --content-base ./dev" 6 | }, 7 | "devDependencies": { 8 | "babel-cli": "^6.5.1", 9 | "babel-core": "^6.5.2", 10 | "babel-eslint": "^4.1.8", 11 | "babel-loader": "^6.2.2", 12 | "babel-preset-es2015": "^6.5.0", 13 | "babel-preset-react": "^6.5.0", 14 | "babel-preset-stage-2": "^6.5.0", 15 | "webpack": "^1.12.4", 16 | "webpack-dev-server": "^1.12.1" 17 | }, 18 | "dependencies": { 19 | "babel-runtime": "^6.5.0", 20 | "react": "^0.14.2", 21 | "react-dom": "^0.14.2", 22 | "react-redux": "^4.0.0", 23 | "redux": "^3.0.4", 24 | "redux-saga-rxjs": "^0.2.0", 25 | "rxjs": "^5.0.0-beta.2" 26 | }, 27 | "author": "Tomas Weiss ", 28 | "license": "MIT" 29 | } 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | 3 | const isFunction = any => typeof any === 'function'; 4 | 5 | const invariant = (condition, message) => { 6 | if (condition) { 7 | throw new Error(`Invariant violation: ${message}`); 8 | } 9 | }; 10 | 11 | export default (...sagas) => { 12 | const subject = new Subject(); 13 | 14 | invariant(sagas.length === 0, 15 | 'Provide at least one saga as argument'); 16 | 17 | invariant(!sagas.every(isFunction), 18 | 'All the provided sagas must be typeof function'); 19 | 20 | return store => { 21 | sagas.forEach(saga => { 22 | const iterable = saga(subject); 23 | 24 | invariant(iterable === subject, 25 | 'It is not allowed to provide identity (empty) saga'); 26 | 27 | iterable.subscribe(dispatchable => store.dispatch(dispatchable)); 28 | }); 29 | 30 | return next => action => { 31 | const result = next(action); 32 | subject.next({action, state: store.getState()}); 33 | return result; 34 | }; 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/actions/actionCreators.js: -------------------------------------------------------------------------------- 1 | import * as Actions from '../constants/actions'; 2 | 3 | export const changeText = text => ({ type: Actions.CHANGE_TEXT, payload: text }); 4 | export const addTodo = title => ({ type: Actions.ADD_TODO, payload: title }); 5 | export const undo = command => ({ type: Actions.UNDO, payload: command }); 6 | export const redo = command => ({ type: Actions.REDO, payload: command }); 7 | export const addTodoId = (clientId, title) => ({ type: Actions.ADD_TODO_ID, payload: { clientId, title }}); 8 | export const clearRedoLog = () => ({ type: Actions.CLEAR_REDO_LOG, payload: null }); 9 | export const todoAdded = (serverId, clientId, command) => ({ type: Actions.TODO_ADDED, payload: { serverId, clientId, command }}); 10 | export const addTodoFailed = clientId => ({ type: Actions.ADD_TODO_FAILED, payload: clientId }); 11 | export const undone = action => ({ type: Actions.UNDONE, payload: action }); 12 | export const undoFailed = serverId => ({ type: Actions.UNDO_FAILED, payload: serverId }); 13 | export const redone = () => ({ type: Actions.REDONE, payload: null }); 14 | -------------------------------------------------------------------------------- /examples/auth/src/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import * as Actions from '../constants/actions'; 2 | 3 | const initialAppState = { 4 | loggedIn: false, 5 | lastTokenRefresh: '', 6 | apiInProgress: false, 7 | credentials: 'saga', 8 | loginError: false 9 | }; 10 | 11 | export default (appState = initialAppState, { type, payload }) => { 12 | 13 | switch (type) { 14 | case Actions.CHANGE_CREDENTIALS: 15 | return { ...appState, credentials: payload }; 16 | 17 | case Actions.LOG_IN: 18 | return { ...appState, apiInProgress: true }; 19 | 20 | case Actions.LOG_OUT: 21 | return initialAppState; 22 | 23 | case Actions.LOGGED_IN: 24 | return { ...appState, loggedIn: true, apiInProgress: false, lastTokenRefresh: payload.refreshed }; 25 | 26 | case Actions.LOG_IN_FAILURE: 27 | return { ...appState, loggedIn: false, apiInProgress: false, loginError: true }; 28 | 29 | case Actions.TOKEN_REFRESHED: 30 | return { ...appState, lastTokenRefresh: payload }; 31 | 32 | case Actions.HIDE_TOAST: 33 | return { ...appState, loginError: false }; 34 | 35 | default: 36 | return appState; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /examples/auth/src/components/Application.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import * as ActionCreators from '../actions/actionCreators'; 5 | 6 | export default connect(appState => appState)(({ dispatch, credentials, loginError, loggedIn, lastTokenRefresh, apiInProgress }) => { 7 | if (apiInProgress) { 8 | return
API call in progress
; 9 | } else if (loggedIn) { 10 | return ( 11 |
12 |
Token last refreshed {lastTokenRefresh}
13 | 14 |
15 | ); 16 | } else { 17 | return ( 18 |
19 | 20 | dispatch(ActionCreators.changeCredentials(ev.target.value))} 25 | />
26 |
27 | {loginError ? Invalid credentials provided : false} 28 |
29 | ); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /examples/real-world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-rxjs-example-real-world", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "./node_modules/.bin/webpack-dev-server --config webpack.config.js --port 3000 --hot --content-base ./dev" 6 | }, 7 | "devDependencies": { 8 | "babel-cli": "^6.5.1", 9 | "babel-core": "^6.5.2", 10 | "babel-eslint": "^4.1.8", 11 | "babel-loader": "^6.2.2", 12 | "babel-preset-es2015": "^6.5.0", 13 | "babel-preset-react": "^6.5.0", 14 | "babel-preset-stage-2": "^6.5.0", 15 | "redux-devtools": "^3.1.1", 16 | "redux-devtools-dock-monitor": "^1.0.1", 17 | "redux-devtools-log-monitor": "^1.0.4", 18 | "webpack": "^1.12.4", 19 | "webpack-dev-server": "^1.12.1" 20 | }, 21 | "dependencies": { 22 | "babel-runtime": "^6.5.0", 23 | "humps": "^1.0.0", 24 | "isomorphic-fetch": "^2.2.1", 25 | "lodash": "^4.0.0", 26 | "normalizr": "^2.0.0", 27 | "react": "^0.14.2", 28 | "react-dom": "^0.14.2", 29 | "react-redux": "^4.0.0", 30 | "react-router": "^2.0.0", 31 | "react-router-redux": "^4.0.0-rc.1", 32 | "redux": "^3.0.4", 33 | "redux-logger": "^2.5.2", 34 | "redux-saga-rxjs": "^0.2.0", 35 | "rxjs": "^5.0.0-beta.2" 36 | }, 37 | "author": "Tomas Weiss ", 38 | "license": "MIT" 39 | } 40 | -------------------------------------------------------------------------------- /examples/real-world/src/actions/index.js: -------------------------------------------------------------------------------- 1 | export const USER_REQUEST = 'USER_REQUEST'; 2 | export const USER_SUCCESS = 'USER_SUCCESS'; 3 | export const USER_FAILURE = 'USER_FAILURE'; 4 | 5 | export function loadUser(login, requiredFields = []) { 6 | return { 7 | type: USER_REQUEST, 8 | login, 9 | requiredFields 10 | }; 11 | } 12 | 13 | export const REPO_REQUEST = 'REPO_REQUEST'; 14 | export const REPO_SUCCESS = 'REPO_SUCCESS'; 15 | export const REPO_FAILURE = 'REPO_FAILURE'; 16 | 17 | export function loadRepo(fullName, requiredFields = []) { 18 | return { 19 | type: REPO_REQUEST, 20 | fullName, 21 | requiredFields 22 | }; 23 | } 24 | 25 | export const STARRED_REQUEST = 'STARRED_REQUEST'; 26 | export const STARRED_SUCCESS = 'STARRED_SUCCESS'; 27 | export const STARRED_FAILURE = 'STARRED_FAILURE'; 28 | 29 | export function loadStarred(login, nextPage) { 30 | return { 31 | type: STARRED_REQUEST, 32 | login, 33 | nextPage 34 | }; 35 | } 36 | 37 | export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; 38 | export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; 39 | export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; 40 | 41 | export function loadStargazers(fullName, nextPage) { 42 | return { 43 | type: STARGAZERS_REQUEST, 44 | fullName, 45 | nextPage 46 | }; 47 | } 48 | 49 | export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; 50 | 51 | // Resets the currently visible error message. 52 | export function resetErrorMessage() { 53 | return { 54 | type: RESET_ERROR_MESSAGE 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/components/Application.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import * as ActionCreators from '../actions/actionCreators'; 5 | 6 | const renderUndoButton = (dispatch, undo, apiInProgress) => { 7 | if (undo.length > 0) { 8 | const lastUndo = undo[undo.length - 1]; 9 | 10 | return ; 11 | } else { 12 | return false; 13 | } 14 | }; 15 | 16 | const renderRedoButton = (dispatch, redo, apiInProgress) => { 17 | if (redo.length > 0) { 18 | const firstRedo = redo[0]; 19 | 20 | return ; 21 | } else { 22 | return false; 23 | } 24 | }; 25 | 26 | export default connect(appState => appState)(({ dispatch, apiInProgress, text, todos, commands }) => { 27 | return ( 28 |
29 |
    30 | {todos.map((todo, index) => ( 31 |
  • {todo.title}
  • 35 | ))} 36 |
37 | dispatch(ActionCreators.changeText(ev.target.value))} 41 | onKeyDown={ev => ev.keyCode === 13 ? dispatch(ActionCreators.addTodo(text)) : null} /> 42 | {renderUndoButton(dispatch, commands.undo, apiInProgress)} 43 | {renderRedoButton(dispatch, commands.redo, apiInProgress)} 44 |
45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /examples/real-world/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../actions'; 2 | import merge from 'lodash/merge'; 3 | import paginate from './paginate'; 4 | import { routerReducer as routing } from 'react-router-redux'; 5 | import { combineReducers } from 'redux'; 6 | 7 | // Updates an entity cache in response to any action with response.entities. 8 | function entities(state = { users: {}, repos: {} }, action) { 9 | if (action.response && action.response.entities) { 10 | return merge({}, state, action.response.entities); 11 | } 12 | 13 | return state; 14 | } 15 | 16 | // Updates error message to notify about the failed fetches. 17 | function errorMessage(state = null, action) { 18 | const { type, error } = action; 19 | 20 | if (type === ActionTypes.RESET_ERROR_MESSAGE) { 21 | return null; 22 | } else if (error) { 23 | return action.error; 24 | } 25 | 26 | return state; 27 | } 28 | 29 | // Updates the pagination data for different actions. 30 | const pagination = combineReducers({ 31 | starredByUser: paginate({ 32 | mapActionToKey: action => action.login, 33 | types: [ 34 | ActionTypes.STARRED_REQUEST, 35 | ActionTypes.STARRED_SUCCESS, 36 | ActionTypes.STARRED_FAILURE 37 | ] 38 | }), 39 | stargazersByRepo: paginate({ 40 | mapActionToKey: action => action.fullName, 41 | types: [ 42 | ActionTypes.STARGAZERS_REQUEST, 43 | ActionTypes.STARGAZERS_SUCCESS, 44 | ActionTypes.STARGAZERS_FAILURE 45 | ] 46 | }) 47 | }); 48 | 49 | const rootReducer = combineReducers({ 50 | entities, 51 | pagination, 52 | errorMessage, 53 | routing 54 | }); 55 | 56 | export default rootReducer; 57 | -------------------------------------------------------------------------------- /examples/real-world/src/components/Explore.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | const GITHUB_REPO = 'https://github.com/rackt/redux'; 4 | 5 | export default class Explore extends Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.handleKeyUp = this.handleKeyUp.bind(this); 10 | this.handleGoClick = this.handleGoClick.bind(this); 11 | } 12 | 13 | componentWillReceiveProps(nextProps) { 14 | if (nextProps.value !== this.props.value) { 15 | this.setInputValue(nextProps.value); 16 | } 17 | } 18 | 19 | getInputValue() { 20 | return this.refs.input.value; 21 | } 22 | 23 | setInputValue(val) { 24 | // Generally mutating DOM is a bad idea in React components, 25 | // but doing this for a single uncontrolled field is less fuss 26 | // than making it controlled and maintaining a state for it. 27 | this.refs.input.value = val; 28 | } 29 | 30 | handleKeyUp(e) { 31 | if (e.keyCode === 13) { 32 | this.handleGoClick(); 33 | } 34 | } 35 | 36 | handleGoClick() { 37 | this.props.onChange(this.getInputValue()); 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 |

Type a username or repo full name and hit 'Go':

44 | 48 | 51 |

52 | Code on Github. 53 |

54 |

55 | Move the DevTools with Ctrl+W or hide them with Ctrl+H. 56 |

57 |
58 | ); 59 | } 60 | } 61 | 62 | Explore.propTypes = { 63 | value: PropTypes.string.isRequired, 64 | onChange: PropTypes.func.isRequired 65 | }; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-rxjs", 3 | "version": "0.3.0", 4 | "description": "Saga pattern for Redux implemented using rxjs", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "src" 9 | ], 10 | "jsnext:main": "src/index.js", 11 | "scripts": { 12 | "build:lib": "./node_modules/.bin/babel src --out-dir lib", 13 | "check": "npm run lint && npm run test", 14 | "lint": "./node_modules/.bin/eslint src/", 15 | "preversion": "npm run check", 16 | "version": "npm run build:lib", 17 | "postversion": "git push && git push --tags", 18 | "prepublish": "npm run build:lib", 19 | "test": "./node_modules/.bin/mocha --require babel-core/register --recursive", 20 | "test:cov": "./node_modules/.bin/babel-node $(npm bin)/isparta cover $(npm bin)/_mocha -- --recursive", 21 | "test:watch": "npm test -- --watch" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/tomkis1/redux-saga-rxjs.git" 26 | }, 27 | "keywords": [ 28 | "redux", 29 | "saga", 30 | "rxjs" 31 | ], 32 | "author": "Tomas Weiss ", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/tomkis1/redux-saga-rxjs/issues" 36 | }, 37 | "engines": { 38 | "node": ">=5.0.0", 39 | "npm": ">=3.0.0" 40 | }, 41 | "homepage": "https://github.com/tomkis1/redux-saga-rxjs#readme", 42 | "peerDependencies": { 43 | "rxjs": "^5.0.0-beta.2" 44 | }, 45 | "devDependencies": { 46 | "babel-cli": "^6.5.1", 47 | "babel-core": "^6.5.2", 48 | "babel-eslint": "^5.0.0", 49 | "babel-preset-es2015": "^6.5.0", 50 | "babel-preset-stage-2": "^6.5.0", 51 | "chai": "^3.5.0", 52 | "eslint": "^2.1.0", 53 | "estraverse-fb": "^1.3.1", 54 | "isparta": "^4.0.0", 55 | "mocha": "^2.4.5", 56 | "redux": "^3.3.1", 57 | "rxjs": "^5.0.0-beta.2", 58 | "sinon": "^1.17.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { hashHistory } from 'react-router'; 4 | import Explore from '../components/Explore'; 5 | import { resetErrorMessage } from '../actions'; 6 | 7 | class App extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.handleChange = this.handleChange.bind(this); 11 | this.handleDismissClick = this.handleDismissClick.bind(this); 12 | } 13 | 14 | handleDismissClick(e) { 15 | this.props.resetErrorMessage(); 16 | e.preventDefault(); 17 | } 18 | 19 | handleChange(nextValue) { 20 | hashHistory.push(`/${nextValue}`); 21 | } 22 | 23 | renderErrorMessage() { 24 | const { errorMessage } = this.props; 25 | if (!errorMessage) { 26 | return null; 27 | } 28 | 29 | return ( 30 |

31 | {errorMessage} 32 | {' '} 33 | ( 35 | Dismiss 36 | ) 37 |

38 | ); 39 | } 40 | 41 | render() { 42 | const { children, inputValue } = this.props; 43 | return ( 44 |
45 | 47 |
48 | {this.renderErrorMessage()} 49 | {children} 50 |
51 | ); 52 | } 53 | } 54 | 55 | App.propTypes = { 56 | // Injected by React Redux 57 | errorMessage: PropTypes.string, 58 | resetErrorMessage: PropTypes.func.isRequired, 59 | inputValue: PropTypes.string.isRequired, 60 | // Injected by React Router 61 | children: PropTypes.node 62 | }; 63 | 64 | function mapStateToProps(state, ownProps) { 65 | return { 66 | errorMessage: state.errorMessage, 67 | inputValue: ownProps.location.pathname.substring(1) 68 | }; 69 | } 70 | 71 | export default connect(mapStateToProps, { 72 | resetErrorMessage 73 | })(App); 74 | -------------------------------------------------------------------------------- /examples/real-world/src/reducers/paginate.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/merge'; 2 | import union from 'lodash/union'; 3 | 4 | // Creates a reducer managing pagination, given the action types to handle, 5 | // and a function telling how to extract the key from an action. 6 | export default function paginate({ types, mapActionToKey }) { 7 | if (!Array.isArray(types) || types.length !== 3) { 8 | throw new Error('Expected types to be an array of three elements.'); 9 | } 10 | if (!types.every(t => typeof t === 'string')) { 11 | throw new Error('Expected types to be strings.'); 12 | } 13 | if (typeof mapActionToKey !== 'function') { 14 | throw new Error('Expected mapActionToKey to be a function.'); 15 | } 16 | 17 | const [ requestType, successType, failureType ] = types; 18 | 19 | function updatePagination(state = { 20 | isFetching: false, 21 | nextPageUrl: undefined, 22 | pageCount: 0, 23 | ids: [] 24 | }, action) { 25 | switch (action.type) { 26 | case requestType: 27 | return merge({}, state, { 28 | isFetching: true 29 | }); 30 | case successType: 31 | return merge({}, state, { 32 | isFetching: false, 33 | ids: union(state.ids, action.response.result), 34 | nextPageUrl: action.response.nextPageUrl, 35 | pageCount: state.pageCount + 1 36 | }); 37 | case failureType: 38 | return merge({}, state, { 39 | isFetching: false 40 | }); 41 | default: 42 | return state; 43 | } 44 | } 45 | 46 | return function updatePaginationByKey(state = {}, action) { 47 | switch (action.type) { 48 | case requestType: 49 | case successType: 50 | case failureType: 51 | const key = mapActionToKey(action); 52 | if (typeof key !== 'string') { 53 | throw new Error('Expected key to be a string.'); 54 | } 55 | return merge({}, state, { 56 | [key]: updatePagination(state[key], action) 57 | }); 58 | default: 59 | return state; 60 | } 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /examples/real-world/src/sagas/api.js: -------------------------------------------------------------------------------- 1 | import { Schema, arrayOf, normalize } from 'normalizr'; 2 | import { camelizeKeys } from 'humps'; 3 | import 'isomorphic-fetch'; 4 | 5 | // Extracts the next page URL from Github API response. 6 | function getNextPageUrl(response) { 7 | const link = response.headers.get('Link'); 8 | if (!link) { 9 | return null; 10 | } 11 | 12 | const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); 13 | if (!nextLink) { 14 | return null; 15 | } 16 | 17 | return nextLink.split(';')[0].slice(1, -1); 18 | } 19 | 20 | const API_ROOT = 'https://api.github.com/'; 21 | 22 | // Fetches an API response and normalizes the result JSON according to schema. 23 | // This makes every API response have the same shape, regardless of how nested it was. 24 | export function callApi(endpoint, schema) { 25 | const fullUrl = (endpoint.indexOf(API_ROOT) === -1) ? API_ROOT + endpoint : endpoint; 26 | 27 | return fetch(fullUrl) 28 | .then(response => 29 | response.json().then(json => ({ json, response })) 30 | ).then(({ json, response }) => { 31 | if (!response.ok) { 32 | return Promise.reject(json); 33 | } 34 | 35 | const camelizedJson = camelizeKeys(json); 36 | const nextPageUrl = getNextPageUrl(response); 37 | 38 | return Object.assign({}, 39 | normalize(camelizedJson, schema), 40 | { nextPageUrl } 41 | ); 42 | }); 43 | } 44 | 45 | // We use this Normalizr schemas to transform API responses from a nested form 46 | // to a flat form where repos and users are placed in `entities`, and nested 47 | // JSON objects are replaced with their IDs. This is very convenient for 48 | // consumption by reducers, because we can easily build a normalized tree 49 | // and keep it updated as we fetch more data. 50 | 51 | // Read more about Normalizr: https://github.com/gaearon/normalizr 52 | 53 | const userSchema = new Schema('users', { 54 | idAttribute: 'login' 55 | }); 56 | 57 | const repoSchema = new Schema('repos', { 58 | idAttribute: 'fullName' 59 | }); 60 | 61 | repoSchema.define({ 62 | owner: userSchema 63 | }); 64 | 65 | // Schemas for Github API responses. 66 | export const Schemas = { 67 | USER: userSchema, 68 | USER_ARRAY: arrayOf(userSchema), 69 | REPO: repoSchema, 70 | REPO_ARRAY: arrayOf(repoSchema) 71 | }; 72 | -------------------------------------------------------------------------------- /test/sagaMiddleware.test.js: -------------------------------------------------------------------------------- 1 | import { spy } from 'sinon'; 2 | import { assert } from 'chai'; 3 | 4 | import sagaMiddleware from '../src/index'; 5 | import { createStore, applyMiddleware } from 'redux'; 6 | 7 | describe('SagaMiddleware test', () => { 8 | it('should allow mounting multiple sagas which may potentially interact in action chain', () => { 9 | const sagaA = iterable => iterable 10 | .filter(({ action }) => action.type === 'FOO') 11 | .map(() => ({type: 'BAR'})); 12 | 13 | const sagaB = iterable => iterable 14 | .filter(({ action }) => action.type === 'BAR') 15 | .map(() => ({type: 'BAZ'})); 16 | 17 | const reducer = spy((appState = 0) => appState); 18 | const store = applyMiddleware(sagaMiddleware(sagaA, sagaB))(createStore)(reducer); 19 | store.dispatch({type: 'FOO'}); 20 | 21 | assert.isTrue(reducer.getCall(1).calledWith(0, {type: 'FOO'})); 22 | assert.isTrue(reducer.getCall(2).calledWith(0, {type: 'BAR'})); 23 | assert.isTrue(reducer.getCall(3).calledWith(0, {type: 'BAZ'})); 24 | }); 25 | 26 | it('should throw an invariant when non function is provided', () => { 27 | try { 28 | sagaMiddleware(() => {}, 'foobar'); 29 | assert.isTrue(false); 30 | } catch (ex) { 31 | assert.equal(ex.message, 'Invariant violation: All the provided sagas must be typeof function'); 32 | } 33 | }); 34 | 35 | it('should throw an invariant when no argument is provided', () => { 36 | try { 37 | sagaMiddleware(); 38 | assert.isTrue(false); 39 | } catch (ex) { 40 | assert.equal(ex.message, 'Invariant violation: Provide at least one saga as argument'); 41 | } 42 | }); 43 | 44 | it('should not allow to accept identity saga', () => { 45 | const identitySaga = iterable => iterable; 46 | 47 | try { 48 | applyMiddleware(sagaMiddleware(identitySaga))(createStore)(appState => appState); 49 | assert.isTrue(false); 50 | } catch (ex) { 51 | assert.equal(ex.message, 'Invariant violation: It is not allowed to provide identity (empty) saga'); 52 | } 53 | }); 54 | 55 | it('should pass the action down the middleware chain', () => { 56 | const saga = iterable => iterable 57 | .filter(({ action }) => action.type === 'FOO') 58 | .map(() => ({type: 'BAR'})); 59 | 60 | const identity = input => input; 61 | const store = { getState: identity, dispatch: identity }; 62 | const action = { type: 'FOO' }; 63 | 64 | const result = sagaMiddleware(saga)(store)(identity)(action) 65 | 66 | assert.equal(result, action); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/UserPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { loadUser, loadStarred } from '../actions'; 4 | import User from '../components/User'; 5 | import Repo from '../components/Repo'; 6 | import List from '../components/List'; 7 | import zip from 'lodash/zip'; 8 | 9 | function loadData(props) { 10 | const { login } = props; 11 | props.loadUser(login, [ 'name' ]); 12 | props.loadStarred(login); 13 | } 14 | 15 | class UserPage extends Component { 16 | constructor(props) { 17 | super(props); 18 | this.renderRepo = this.renderRepo.bind(this); 19 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); 20 | } 21 | 22 | componentWillMount() { 23 | loadData(this.props); 24 | } 25 | 26 | componentWillReceiveProps(nextProps) { 27 | if (nextProps.login !== this.props.login) { 28 | loadData(nextProps); 29 | } 30 | } 31 | 32 | handleLoadMoreClick() { 33 | this.props.loadStarred(this.props.login, true); 34 | } 35 | 36 | renderRepo([ repo, owner ]) { 37 | return ( 38 | 41 | ); 42 | } 43 | 44 | render() { 45 | const { user, login } = this.props; 46 | if (!user) { 47 | return

Loading {login}’s profile...

; 48 | } 49 | 50 | const { starredRepos, starredRepoOwners, starredPagination } = this.props; 51 | return ( 52 |
53 | 54 |
55 | 60 |
61 | ); 62 | } 63 | } 64 | 65 | UserPage.propTypes = { 66 | login: PropTypes.string.isRequired, 67 | user: PropTypes.object, 68 | starredPagination: PropTypes.object, 69 | starredRepos: PropTypes.array.isRequired, 70 | starredRepoOwners: PropTypes.array.isRequired, 71 | loadUser: PropTypes.func.isRequired, 72 | loadStarred: PropTypes.func.isRequired 73 | }; 74 | 75 | function mapStateToProps(state, ownProps) { 76 | const { login } = ownProps.params; 77 | const { 78 | pagination: { starredByUser }, 79 | entities: { users, repos } 80 | } = state; 81 | 82 | const starredPagination = starredByUser[login] || { ids: [] }; 83 | const starredRepos = starredPagination.ids.map(id => repos[id]); 84 | const starredRepoOwners = starredRepos.map(repo => users[repo.owner]); 85 | 86 | return { 87 | login, 88 | starredRepos, 89 | starredRepoOwners, 90 | starredPagination, 91 | user: users[login] 92 | }; 93 | } 94 | 95 | export default connect(mapStateToProps, { 96 | loadUser, 97 | loadStarred 98 | })(UserPage); 99 | -------------------------------------------------------------------------------- /examples/real-world/src/containers/RepoPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { loadRepo, loadStargazers } from '../actions'; 4 | import Repo from '../components/Repo'; 5 | import User from '../components/User'; 6 | import List from '../components/List'; 7 | 8 | function loadData(props) { 9 | const { fullName } = props; 10 | props.loadRepo(fullName, [ 'description' ]); 11 | props.loadStargazers(fullName); 12 | } 13 | 14 | class RepoPage extends Component { 15 | constructor(props) { 16 | super(props); 17 | this.renderUser = this.renderUser.bind(this); 18 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); 19 | } 20 | 21 | componentWillMount() { 22 | loadData(this.props); 23 | } 24 | 25 | componentWillReceiveProps(nextProps) { 26 | if (nextProps.fullName !== this.props.fullName) { 27 | loadData(nextProps); 28 | } 29 | } 30 | 31 | handleLoadMoreClick() { 32 | this.props.loadStargazers(this.props.fullName, true); 33 | } 34 | 35 | renderUser(user) { 36 | return ( 37 | 39 | ); 40 | } 41 | 42 | render() { 43 | const { repo, owner, name } = this.props; 44 | if (!repo || !owner) { 45 | return

Loading {name} details...

; 46 | } 47 | 48 | const { stargazers, stargazersPagination } = this.props; 49 | return ( 50 |
51 | 53 |
54 | 59 |
60 | ); 61 | } 62 | } 63 | 64 | RepoPage.propTypes = { 65 | repo: PropTypes.object, 66 | fullName: PropTypes.string.isRequired, 67 | name: PropTypes.string.isRequired, 68 | owner: PropTypes.object, 69 | stargazers: PropTypes.array.isRequired, 70 | stargazersPagination: PropTypes.object, 71 | loadRepo: PropTypes.func.isRequired, 72 | loadStargazers: PropTypes.func.isRequired 73 | }; 74 | 75 | function mapStateToProps(state, ownProps) { 76 | const { login, name } = ownProps.params; 77 | const { 78 | pagination: { stargazersByRepo }, 79 | entities: { users, repos } 80 | } = state; 81 | 82 | const fullName = `${login}/${name}`; 83 | const stargazersPagination = stargazersByRepo[fullName] || { ids: [] }; 84 | const stargazers = stargazersPagination.ids.map(id => users[id]); 85 | 86 | return { 87 | fullName, 88 | name, 89 | stargazers, 90 | stargazersPagination, 91 | repo: repos[fullName], 92 | owner: users[login] 93 | }; 94 | } 95 | 96 | export default connect(mapStateToProps, { 97 | loadRepo, 98 | loadStargazers 99 | })(RepoPage); 100 | -------------------------------------------------------------------------------- /examples/auth/src/sagas/authSaga.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import moment from 'moment'; 3 | 4 | import * as Actions from '../constants/actions'; 5 | import * as ActionCreators from '../actions/actionCreators'; 6 | 7 | const logInApi = crendetials => new Promise((res, rej) => setTimeout(() => { 8 | if (crendetials === 'saga') { 9 | res(moment().format('HH:mm:ss')); 10 | } else { 11 | rej('Invalid credentials'); 12 | } 13 | }, 500)); 14 | 15 | const createDelay = time => new Promise(res => setTimeout(() => res(), time)); 16 | const actionOrder = (actions, order) => actions.every(({ action }, index) => action.type === order[index]); 17 | const actionPredicate = actions => ({ action }) => actions.some(someAction => someAction === action.type); 18 | 19 | const AUTH_EXPIRATION = 1000; 20 | 21 | const LOG_OUT_ACTIONS_ORDER = [ 22 | Actions.LOG_IN, 23 | Actions.LOGGED_IN, 24 | Actions.LOG_OUT 25 | ]; 26 | 27 | // User clicked the log in button, 28 | // call the API and respond with either success or failure 29 | const authGetTokenSaga = iterable => iterable 30 | .filter(actionPredicate([Actions.LOG_IN])) 31 | .flatMap(({ action }) => Observable 32 | .fromPromise(logInApi(action.payload)) 33 | .map(refreshed => ActionCreators.loggedIn(action.payload, refreshed)) 34 | .catch(() => Observable.of(ActionCreators.logInFailure()))); 35 | 36 | // After the user is successfuly logged in, 37 | // let's schedule an infinite interval stream 38 | // which can be interrupted by LOG_OUT action 39 | const authRefreshTokenSaga = iterable => iterable 40 | .filter(actionPredicate([Actions.LOGGED_IN])) 41 | .flatMap(({ action }) => Observable 42 | .interval(AUTH_EXPIRATION) 43 | .flatMap(() => Observable 44 | .fromPromise(logInApi(action.payload.credentials)) 45 | .map(refreshed => ActionCreators.tokenRefreshed(refreshed)) 46 | ) 47 | .takeUntil(iterable.filter(actionPredicate([Actions.LOG_OUT]))) 48 | ); 49 | 50 | // Observe all the actions in specific order 51 | // to determine whether user wants to log out 52 | const authHandleLogOutSaga = iterable => iterable 53 | .filter(actionPredicate(LOG_OUT_ACTIONS_ORDER)) 54 | .bufferCount(LOG_OUT_ACTIONS_ORDER.length) 55 | .filter(actions => actionOrder(actions, LOG_OUT_ACTIONS_ORDER)) 56 | .map(() => ActionCreators.logOut()); 57 | 58 | // After LOG_IN_FAILURE kicks-in, start a race 59 | // between 5000ms delay and CHANGE_CREDENTIALS action, 60 | // meaning that either timeout or changing credentials 61 | // hides the toast 62 | const authShowLogInFailureToast = iterable => iterable 63 | .filter(actionPredicate([Actions.LOG_IN_FAILURE])) 64 | .flatMap(() => 65 | Observable.race( 66 | Observable.fromPromise(createDelay(5000)), 67 | iterable.filter(actionPredicate([Actions.CHANGE_CREDENTIALS])) 68 | ) 69 | .map(() => ActionCreators.hideToast())); 70 | 71 | // Just merge all the sub-sagas into one sream 72 | export default iterable => Observable.merge( 73 | authGetTokenSaga(iterable), 74 | authRefreshTokenSaga(iterable), 75 | authHandleLogOutSaga(iterable), 76 | authShowLogInFailureToast(iterable) 77 | ); 78 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import * as Actions from '../constants/actions'; 2 | 3 | const initialAppState = { 4 | text: '', 5 | todos: [], 6 | apiInProgress: false, 7 | commands: { 8 | undo: [], 9 | redo: [] 10 | } 11 | }; 12 | 13 | export default (appState = initialAppState, { type, payload }) => { 14 | 15 | switch (type) { 16 | case Actions.CHANGE_TEXT: 17 | return { ...appState, text: payload }; 18 | 19 | case Actions.ADD_TODO_ID: 20 | return { 21 | ...appState, 22 | apiInProgress: true, 23 | todos: [...appState.todos, { id: payload.clientId, title: payload.title, transient: true }] 24 | }; 25 | 26 | case Actions.TODO_ADDED: 27 | return { 28 | ...appState, 29 | text: '', 30 | apiInProgress: false, 31 | todos: appState.todos.map(todo => { 32 | if (todo.id === payload.clientId) { 33 | return { ...todo, id: payload.serverId, transient: false }; 34 | } else { 35 | return todo; 36 | } 37 | }), 38 | commands: { 39 | ...appState.commands, 40 | undo: [...appState.commands.undo, payload] 41 | } 42 | }; 43 | 44 | case Actions.ADD_TODO_FAILED: 45 | return { 46 | ...appState, 47 | apiInProgress: false, 48 | todos: appState.todos.filter(todo => todo.id !== payload) 49 | }; 50 | 51 | 52 | case Actions.REDONE: 53 | const redo = [...appState.commands.redo]; 54 | redo.shift(); 55 | 56 | return { 57 | ...appState, 58 | commands: { 59 | ...appState.commands, 60 | redo: redo 61 | } 62 | }; 63 | 64 | case Actions.CLEAR_REDO_LOG: 65 | return { 66 | ...appState, 67 | commands: { 68 | ...appState.commands, 69 | redo: [] 70 | } 71 | }; 72 | 73 | case Actions.UNDO: 74 | return { 75 | ...appState, 76 | apiInProgress: true, 77 | todos: appState.todos.map(todo => { 78 | if (todo.id === payload.serverId) { 79 | return { ...todo, transient: true }; 80 | } else { 81 | return todo; 82 | } 83 | }) 84 | }; 85 | 86 | case Actions.UNDO_FAILED: 87 | return { 88 | ...appState, 89 | apiInProgress: false, 90 | todos: appState.todos.map(todo => { 91 | if (todo.id === payload) { 92 | return { ...todo, transient: false }; 93 | } else { 94 | return todo; 95 | } 96 | }) 97 | }; 98 | 99 | case Actions.UNDONE: 100 | const undo = [...appState.commands.undo]; 101 | undo.pop(); 102 | 103 | return { 104 | ...appState, 105 | apiInProgress: false, 106 | todos: appState.todos.filter(todo => todo.id !== payload.serverId), 107 | commands: { 108 | ...appState.commands, 109 | undo, 110 | redo: [payload.command, ...appState.commands.redo] 111 | } 112 | }; 113 | 114 | default: 115 | return appState; 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /examples/real-world/src/sagas/realWorldSaga.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Schemas, callApi } from './api'; 3 | import { 4 | USER_REQUEST, USER_SUCCESS, USER_FAILURE, 5 | STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE, 6 | REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE, 7 | STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE 8 | } from '../actions'; 9 | 10 | const callApiSaga = (endpoint, schema, requestAction, successActionType, failureActionType) => 11 | Observable.fromPromise(callApi(endpoint, schema)) 12 | .map(response => ({ 13 | ...requestAction, 14 | type: successActionType, 15 | response 16 | })) 17 | .catch(error => Observable.of({ 18 | ...requestAction, 19 | type: failureActionType, 20 | error: error.message || 'Something bad happened' 21 | })); 22 | 23 | const fetchUser = (userRequestAction, login) => callApiSaga( 24 | `users/${login}`, 25 | Schemas.USER, 26 | userRequestAction, 27 | USER_SUCCESS, 28 | USER_FAILURE 29 | ); 30 | 31 | const fetchStarred = (starredRequestAction, url) => callApiSaga( 32 | url, 33 | Schemas.REPO_ARRAY, 34 | starredRequestAction, 35 | STARRED_SUCCESS, 36 | STARRED_FAILURE 37 | ); 38 | 39 | const fetchRepo = (repoRequestAction, fullName) => callApiSaga( 40 | `repos/${fullName}`, 41 | Schemas.REPO, 42 | repoRequestAction, 43 | REPO_SUCCESS, 44 | REPO_FAILURE 45 | ); 46 | 47 | const fetchStargazers = (stargazersRequestAction, url) => callApiSaga( 48 | url, 49 | Schemas.USER_ARRAY, 50 | stargazersRequestAction, 51 | STARGAZERS_SUCCESS, 52 | STARGAZERS_FAILURE 53 | ); 54 | 55 | const loadUserSaga = iterable => iterable 56 | .filter(({ action }) => action.type === USER_REQUEST) 57 | .filter(({ action, state }) => { 58 | const { login, requiredFields } = action; 59 | const user = state.entities.users[login]; 60 | return !(user && requiredFields.every(key => user.hasOwnProperty(key))); 61 | }) 62 | .flatMap(({ action }) => fetchUser(action, action.login)); 63 | 64 | const loadRepoSaga = iterable => iterable 65 | .filter(({ action }) => action.type === REPO_REQUEST) 66 | .filter(({ action, state }) => { 67 | const { fullName, requiredFields } = action; 68 | const repo = state.entities.repos[fullName]; 69 | return !(repo && requiredFields.every(key => repo.hasOwnProperty(key))); 70 | }) 71 | .flatMap(({ action }) => fetchRepo(action, action.fullName)); 72 | 73 | const paginationPredicate = ({ pageCount, nextPage }) => !pageCount > 0 || nextPage; 74 | 75 | const loadStarredSaga = iterable => iterable 76 | .filter(({ action }) => action.type === STARRED_REQUEST) 77 | .map(({ action, state }) => { 78 | const { login, nextPage } = action; 79 | const { 80 | pageCount = 0, 81 | nextPageUrl = `users/${login}/starred` 82 | } = state.pagination.starredByUser[login] || {}; 83 | 84 | return { 85 | action, 86 | pageCount, 87 | nextPage, 88 | nextPageUrl 89 | }; 90 | }) 91 | .filter(paginationPredicate) 92 | .flatMap(({ action, nextPageUrl }) => fetchStarred(action, nextPageUrl)); 93 | 94 | const loadStargazersSaga = iterable => iterable 95 | .filter(({ action }) => action.type === STARGAZERS_REQUEST) 96 | .map(({ action, state }) => { 97 | const { fullName, nextPage } = action; 98 | const { 99 | pageCount = 0, 100 | nextPageUrl = `repos/${fullName}/stargazers` 101 | } = state.pagination.stargazersByRepo[fullName] || {}; 102 | 103 | return { 104 | action, 105 | pageCount, 106 | nextPage, 107 | nextPageUrl 108 | }; 109 | }) 110 | .filter(paginationPredicate) 111 | .flatMap(({ action, nextPageUrl }) => fetchStargazers(action, nextPageUrl)); 112 | 113 | export default iterable => Observable.merge( 114 | loadUserSaga(iterable), 115 | loadStarredSaga(iterable), 116 | loadRepoSaga(iterable), 117 | loadStargazersSaga(iterable) 118 | ); 119 | -------------------------------------------------------------------------------- /examples/undo-redo-optimistic/src/sagas/commandSaga.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import * as Actions from '../constants/actions'; 4 | import * as ActionCreators from '../actions/actionCreators'; 5 | 6 | let globalClientId = 0; 7 | const API_TIME = 300; 8 | 9 | const unstableApi = info => { 10 | console.log(info); 11 | 12 | return new Promise((res, rej) => 13 | setTimeout(() => 14 | Math.random() > 0.3 ? res(Math.random()) : rej(), API_TIME)); 15 | }; 16 | 17 | const actionPredicate = actions => ({ action }) => actions.some(someAction => someAction === action.type); 18 | 19 | // Filter out all the user initiated (non empty) ADD_TODO actions 20 | // and map them to provide client generated ID (the ID is used for optimistic updates) 21 | // 22 | // Handling ADD_TODO_ID in reducer will create transient TODO with clientId assigned. 23 | // This todo may eventually get deleted if the API fails 24 | const generateClientIdSaga = iterable => iterable 25 | .filter(actionPredicate([Actions.ADD_TODO])) 26 | .filter(({ action }) => action.payload !== '') 27 | .map(({ action }) => ActionCreators.addTodoId(globalClientId++, action.payload)); 28 | 29 | // The main saga responsible for calling the API. 30 | // Whenever ADD_TODO_ID action kicks in, API call is executed. 31 | // 32 | // When API succeeds we need to provide `clientId` and `serverId` to reducer so that it's possible to assign the serverId to 33 | // transient todo record which now becomes persistent (commited). Also it's important to provide original action which 34 | // will act as "Command" which can be later used for redo. 35 | // 36 | // If the API fails, we'll provide just clientId so that the Todo can be rolled back 37 | const createTodoSaga = iterable => iterable 38 | .filter(actionPredicate([Actions.ADD_TODO_ID])) 39 | .flatMap(({ action }) => { 40 | const clientId = action.payload.clientId; 41 | const title = action.payload.title; 42 | 43 | return Observable.fromPromise(unstableApi(`Create todo - ${title}`)) 44 | .map(serverId => ActionCreators.todoAdded(serverId, clientId, action)) 45 | .catch(() => 46 | Observable.of(ActionCreators.addTodoFailed(clientId))); 47 | }); 48 | 49 | // Whenever UNDO action kicks in 50 | // API call for undoing is executed and reducer is notified about success/failure by dispatching action 51 | const undoSaga = iterable => iterable 52 | .filter(actionPredicate([Actions.UNDO])) 53 | .flatMap(({ action }) => 54 | Observable.fromPromise(unstableApi(`Undo todo - ${action.payload.serverId}`)) 55 | .map(() => ActionCreators.undone(action.payload)) 56 | .catch(() => 57 | Observable.of(ActionCreators.undoFailed(action.payload.serverId)))); 58 | 59 | // This is a bit tricky 60 | // 1) Redo saga must map REDO action to original Command (which in our case is ADD_TODO_ID action) 61 | // 2) However, we need to wait for successful redoing of the command - the second branch of the merge 62 | const redoSaga = iterable => iterable 63 | .filter(actionPredicate([Actions.REDO])) 64 | .flatMap(({ action }) => Observable.merge( 65 | Observable.of(action.payload), 66 | iterable 67 | .filter(actionPredicate([Actions.TODO_ADDED, Actions.ADD_TODO_FAILED])) 68 | .take(1) 69 | .filter(actionPredicate([Actions.TODO_ADDED])) 70 | .map(() => ActionCreators.redone()) 71 | )); 72 | 73 | // Whenever we get ADD_TODO immediately followed by TODO_ADDED we 74 | // want to clear the redo log, because when user creates new todo, it doesn't 75 | // make sense to keep the previous redo log. 76 | const clearRedoLogSaga = iterable => iterable 77 | .filter(actionPredicate([Actions.ADD_TODO])) 78 | .flatMap(() => iterable 79 | .filter(actionPredicate([Actions.TODO_ADDED, Actions.ADD_TODO_FAILED])) 80 | .take(1) 81 | .filter(actionPredicate([Actions.TODO_ADDED])) 82 | .map(() => ActionCreators.clearRedoLog())); 83 | 84 | export default iterable => Observable.merge( 85 | generateClientIdSaga(iterable), 86 | createTodoSaga(iterable), 87 | undoSaga(iterable), 88 | redoSaga(iterable), 89 | clearRedoLogSaga(iterable) 90 | ); 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-saga-rxjs 2 | ============= 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Dependencies][dependencies]][npm-url] 6 | [![Build status][travis-image]][travis-url] 7 | [![Downloads][downloads-image]][downloads-url] 8 | 9 | 10 | > RxJS implementation of [Saga pattern](https://www.youtube.com/watch?v=xDuwrtwYHu8) for [redux](https://github.com/reactjs/redux) 11 | 12 | # This project is no longer updated and mantained, it was intended as proof of concept, there is a real library with awesome documentation & API, please have a look at [redux-observable](https://github.com/redux-observable/redux-observable). 13 | 14 | ## Introduction 15 | 16 | ### Redux is great, long running transactions are problematic 17 | Redux gives us great power but with great power comes great responsibility. It's possible to build amazing, extensible, robust and scalable architecture, yet it's not as easy as it looks, because there are some unknowns which hasn't been fully solved and proven e.g. Local component State / Side effects / Long running transactions etc. One common problem that probably every developer will sooner or later have to face is communication with an API. 18 | 19 | Reading through the Redux docs will guide you to use [`thunk-middleware`](https://github.com/gaearon/redux-thunk). Thunk middleware allows you to dispatch `function` instead of `object`, this is cool because the `function` gets called providing `dispatch` and `getState` as arguments, therefore it allows you to call the API inside the function and dispatch an action holding payload when API response arrives - this is an asynchronous operation. 20 | 21 | ```javascript 22 | 23 | const apiAction = (dispatch, getState) => { 24 | dispatch({type: 'STARTED_LOADING_DATA'}); 25 | 26 | api().then(response => dispatch({type: 'DATA_LOADED', response})); 27 | } 28 | 29 | const view = dispatch => ( 30 | 31 | ); 32 | 33 | ``` 34 | 35 | The example above is something we could call long running transaction, in other words it's some kind of logic group which groups more than single Action. Long running transactions does not necessarily need to be Asynchronous, in this example the long running transaction is also asynchronous because those actions are not dispatched synchronously in sequence. 36 | 37 | 38 | ```javascript 39 | const synchronousLongRunningTransaction = (dispatch, getState) => { 40 | dispatch({type: 'FOO'}); 41 | 42 | if (getState().foo === 1) { 43 | dispatch({type: 'BAR'}); 44 | } 45 | } 46 | ``` 47 | 48 | The example above is also a long running transaction yet it's not asynchronous and does not yield any side effects. 49 | 50 | 51 | ### What is Saga pattern? 52 | Saga is a pattern for orchestrating long running transactions... TODO 53 | 54 | ### Why is Saga good and probably mandatory for Redux? 55 | Redux is just predictable state container. We can say that all it does (and it does very well) is holding refrence of your application state and providing interface to mutate it (dispatching actions) in single transaction. Whenever any action (interaction with the UI) is dispatched, the reducer is called with provided application state and action to perform the mutation. The simplest model of Redux is State Machine because it defines two concepts: States and Transitions where in context of Redux - States are references to application state snapshot in specific time and Transitions are Actions. 56 | 57 | State Machine is missing one important piece and it's the transition history. State machine knows current state but it doesn't know anything about how it got to the state, it's missing **Sequence of transitions**. However, the Sequence of transitions is often very important information. Let's imagine you want to withdraw money from ATM, the first thing you need to do is enter your credit card and then enter the PIN. So the sequence of transitions could be as follows: `WAITING_FOR_CREDIT_CARD` -> `CARD_INSERTED` -> `AUTHORIZED` or `REJECTED` but we would like to allow user enter invalid PIN 3 times before rejecting. 58 | 59 | So first naive approach would be model of State machine which covers all the possible states and transitions between them: 60 | 61 | ![atm-1](./docs/atm_1.png) 62 | 63 | As you can see, for simple use case the State machine is quite complex, now let's have a look at the State machine which would probably correspond with the way you'd implemented it in Redux. 64 | 65 | ![atm-2](./docs/atm_2.png) 66 | 67 | You might have spotted something ugly in the diagram and it's the counter of attempts in the form of intermediate state. Yes, in traditional Redux you'd have to keep the information in your application state because State Machine does not allow you to keep track of transitions history. And that's exactly the point where Saga comes into play. 68 | 69 | **Long running transaction in context of Redux means specific sequence of dispatched actions**, without Saga there's no other way to know about the sequence but storing intermediate state in your application state. 70 | 71 | ```javascript 72 | const reducer = (appState, { type }) { 73 | switch (type) { 74 | case 'VALID_PIN_ENTERED': 75 | return { ...appState, authorized: true }; 76 | 77 | case 'PIN_REJECTED': 78 | if (appState.attempt >= 2) { 79 | return { ...appState, authFailure: true }; 80 | } else { 81 | return { ...appState, attempt: appState.attempt + 1 }; 82 | } 83 | 84 | default: 85 | return appState; 86 | } 87 | } 88 | ``` 89 | 90 | However, using Saga we don't need to store the intermediate state in the application state and reducers because all this lives in Saga. **The reducer would be just a simple projector of your Domain Events (Actions), where the projection is application state**. 91 | 92 | ```javascript 93 | const reducer = (appState, { type }) { 94 | switch (type) { 95 | case 'AUTHORIZED': 96 | return { ...appState, authorized: true }; 97 | 98 | case 'REJECTED': 99 | return { ...appState, authFailure: true }; 100 | 101 | default: 102 | return appState; 103 | } 104 | } 105 | ``` 106 | 107 | ## Comparison 108 | 109 | ### Is thunk-middleware Saga pattern? 110 | 111 | [...TODO] 112 | 113 | ### What's the relation between Saga pattern and Side effects? 114 | 115 | So why people say that Saga is a great pattern for Side effects? Let's take an API call as example - you'll probably have one action (`API_REQUESTED`) dispatched when user clicks the button to load data, which presumably displays loading spinner and another action to process the response (`API_FINISHED`) and this is all in single, long running transaction which Saga can handle. 116 | 117 | We need to distinguish between Side effects and Asynchronous long running transaction. The former stands for some effect which is not the primary goal of the calling function (mutation of some external state / calling XHR / logging to console...) while the latter stands for asynchronous sequence of actions which is some logical group (transaction). Saga solves the latter, it's just an implementation detail that it's capable of solving side effects. We could still say that it should be forbidden to perform side effects in Sagas as it is in Reducers - just a minor implementation detail. 118 | 119 | ### Difference between redux-saga-rxjs and redux-saga 120 | 121 | [...TODO] 122 | 123 | ## Usage 124 | 125 | Install the package via `npm` - `npm install redux-saga-rxjs --save`. Package exposes single middleware to be used in your Redux application. The middleware takes Sagas as its arguments. 126 | 127 | ```javascript 128 | import { createStore, applyMiddleware } from 'redux'; 129 | import sagaMiddleware from 'redux-saga-rxjs'; 130 | 131 | // Example of simplest saga 132 | // Whenever action FOO kicks-in, Saga will dispatch 133 | // BAR action 134 | const saga = iterable => iterable 135 | .filter(({ action, state }) => action.type === 'FOO') 136 | .map(() => ({ type: 'BAR' })); 137 | 138 | const storeFactory = applyMiddleware( 139 | sagaMiddleware(saga, sagaFoo...) // You can provide more than one Saga here 140 | )(createStore); 141 | 142 | // Very simple identity reducer which is not doing anything 143 | const identityReducer = appState => appState; 144 | 145 | // Use the store as you are used to in traditional Redux application 146 | const store = storeFactory(identityReducer); 147 | ``` 148 | 149 | ## Development 150 | 151 | ``` 152 | npm install 153 | npm run test:watch 154 | ``` 155 | 156 | 157 | [npm-image]: https://img.shields.io/npm/v/redux-saga-rxjs.svg?style=flat-square 158 | [npm-url]: https://npmjs.org/package/redux-saga-rxjs 159 | [travis-image]: https://img.shields.io/travis/salsita/redux-saga-rxjs.svg?style=flat-square 160 | [travis-url]: https://travis-ci.org/salsita/redux-saga-rxjs 161 | [downloads-image]: http://img.shields.io/npm/dm/redux-saga-rxjs.svg?style=flat-square 162 | [downloads-url]: https://npmjs.org/package/redux-saga-rxjs 163 | [dependencies]: https://david-dm.org/salsita/redux-saga-rxjs.svg 164 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", // https://github.com/babel/babel-eslint 3 | "experimental": true, 4 | "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments 5 | "browser": true, // browser global variables 6 | "node": true, // Node.js global variables and Node.js-specific rules 7 | "mocha": true 8 | }, 9 | "ecmaFeatures": { 10 | "arrowFunctions": true, 11 | "blockBindings": true, 12 | "classes": true, 13 | "defaultParams": true, 14 | "destructuring": true, 15 | "forOf": true, 16 | "generators": false, 17 | "modules": true, 18 | "objectLiteralComputedProperties": true, 19 | "objectLiteralDuplicateProperties": false, 20 | "objectLiteralShorthandMethods": true, 21 | "objectLiteralShorthandProperties": true, 22 | "spread": true, 23 | "superInFunctions": true, 24 | "templateStrings": true 25 | }, 26 | "rules": { 27 | /** 28 | * Strict mode 29 | */ 30 | // babel inserts "use strict"; for us 31 | "strict": [2, "never"], // http://eslint.org/docs/rules/strict 32 | 33 | /** 34 | * ES6 35 | */ 36 | "no-var": 2, // http://eslint.org/docs/rules/no-var 37 | "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const 38 | 39 | /** 40 | * Variables 41 | */ 42 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 43 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 44 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 45 | "vars": "local", 46 | "args": "after-used" 47 | }], 48 | "no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define 49 | 50 | /** 51 | * Possible errors 52 | */ 53 | "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle 54 | "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign 55 | "no-console": 1, // http://eslint.org/docs/rules/no-console 56 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 57 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 58 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 59 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 60 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 61 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 62 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 63 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 64 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 65 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 66 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 67 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 68 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 69 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 70 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 71 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 72 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 73 | "block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var 74 | 75 | /** 76 | * Best practices 77 | */ 78 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 79 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 80 | "default-case": 2, // http://eslint.org/docs/rules/default-case 81 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 82 | "allowKeywords": true 83 | }], 84 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 85 | "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in 86 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 87 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 88 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 89 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 90 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 91 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 92 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 93 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 94 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 95 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 96 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 97 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 98 | "no-new": 2, // http://eslint.org/docs/rules/no-new 99 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 100 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 101 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 102 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 103 | "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign 104 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 105 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 106 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 107 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 108 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 109 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 110 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 111 | "no-with": 2, // http://eslint.org/docs/rules/no-with 112 | "radix": 2, // http://eslint.org/docs/rules/radix 113 | "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top 114 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 115 | "yoda": 2, // http://eslint.org/docs/rules/yoda 116 | 117 | /** 118 | * Style 119 | */ 120 | "indent": [2, 2], // http://eslint.org/docs/rules/indent 121 | "brace-style": [2, // http://eslint.org/docs/rules/brace-style 122 | "1tbs", { 123 | "allowSingleLine": true 124 | }], 125 | "quotes": [ 126 | 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 127 | ], 128 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 129 | "properties": "never" 130 | }], 131 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 132 | "before": false, 133 | "after": true 134 | }], 135 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 136 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 137 | "func-names": 1, // http://eslint.org/docs/rules/func-names 138 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 139 | "beforeColon": false, 140 | "afterColon": true 141 | }], 142 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 143 | "newIsCap": true 144 | }], 145 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 146 | "max": 2 147 | }], 148 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 149 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 150 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 151 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 152 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 153 | "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var 154 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 155 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 156 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 157 | "before": false, 158 | "after": true 159 | }], 160 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 161 | "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren 162 | "space-infix-ops": 2 // http://eslint.org/docs/rules/space-infix-ops 163 | } 164 | } --------------------------------------------------------------------------------