├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── Makefile ├── README.md ├── package.json ├── react.js └── src ├── __tests__ ├── createConnector-test.js ├── init.js ├── jsdom.js ├── observableFromStore-test.js └── observableMiddleware-test.js ├── bindActionCreators.js ├── createConnector.js ├── index.js ├── isObservable.js ├── observableFromStore.js ├── observableMiddleware.js └── react.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "mocha": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | "expect": true 9 | }, 10 | "rules": { 11 | "react/jsx-uses-react": 2, 12 | "react/jsx-uses-vars": 2, 13 | "react/react-in-jsx-scope": 2, 14 | 15 | "padded-blocks": 0, 16 | "no-use-before-define": [2, "nofunc"], 17 | "no-unused-expressions": 0 18 | }, 19 | "plugins": [ 20 | "react" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | 3 | MOCHA_ARGS= --compilers js:babel/register \ 4 | --recursive \ 5 | --require src/__tests__/init.js 6 | MOCHA_TARGET=src/**/*-test.js 7 | 8 | build: 9 | $(BIN)/babel src --out-dir lib 10 | mv lib/react.js react.js 11 | 12 | clean: 13 | rm -rf lib 14 | rm -f react.js 15 | 16 | test: lint 17 | NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) $(MOCHA_TARGET) 18 | 19 | test-watch: lint 20 | NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) -w $(MOCHA_TARGET) 21 | 22 | lint: 23 | $(BIN)/eslint src 24 | 25 | PHONY: build clean test test-watch lint 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-rx 2 | ======== 3 | 4 | [![build status](https://img.shields.io/travis/acdlite/redux-rx/master.svg?style=flat-square)](https://travis-ci.org/acdlite/redux-rx) 5 | [![npm version](https://img.shields.io/npm/v/redux-rx.svg?style=flat-square)](https://www.npmjs.com/package/redux-rx) 6 | 7 | RxJS utilities for Redux. Includes 8 | 9 | - A utility to create Connector-like smart components using RxJS sequences. 10 | - A special version of `bindActionCreators()` that works with sequences. 11 | - An [FSA](https://github.com/acdlite/flux-standard-action)-compliant observable [middleware](https://github.com/gaearon/redux/blob/master/docs/middleware.md) 12 | - A utility to create a sequence of states from a Redux store. 13 | 14 | ```js 15 | npm install --save redux-rx rx 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```js 21 | import { createConnector } from 'redux-rx/react'; 22 | import { bindActionCreators, observableMiddleware, observableFromStore } from 'redux-rx'; 23 | ``` 24 | 25 | ## `createConnector(selectState, ?render)` 26 | 27 | This lets you create Connector-like smart components using RxJS sequences. `selectState()` accepts three sequences as parameters 28 | 29 | - `props$` - A sequence of props passed from the owner 30 | - `state$` - A sequence of state from the Redux store 31 | - `dispatch$` - A sequence representing the `dispatch()` method. In real-world usage, this sequence only has a single value, but it's provided as a sequence for correctness. 32 | 33 | `selectState()` should return a sequence of props that can be passed to the child. This provides a great integration point for [sideways data-loading](https://github.com/facebook/react/issues/3398). 34 | 35 | Here's a simple example using web sockets: 36 | 37 | ```js 38 | const TodoConnector = createConnector((props$, state$, dispatch$) => { 39 | // Special version of bindActionCreators that works with sequences; see below 40 | const actionCreators$ = bindActionCreators(actionCreators, dispatch$); 41 | const selectedState$ = state$.map(s => s.messages); 42 | 43 | // Connect to a websocket using rx-dom 44 | const $ws = fromWebSocket('ws://chat.foobar.org').map(e => e.data) 45 | .withLatestFrom(actionCreators$, (message, ac) => 46 | () => ac.receiveMessage(message) 47 | ) 48 | .do(dispatchAction => dispatchAction()); // Dispatch action for new messages 49 | 50 | return combineLatest( 51 | props$, selectedState$, actionCreators$, $ws, 52 | (props, selectedState, actionCreators) => ({ 53 | ...props, 54 | ...selectedState, 55 | ...actionCreators 56 | })); 57 | }); 58 | ``` 59 | 60 | Pretty simple, right? Notice how there are no event handlers to clean up, no `componentWillReceiveProps()`, no `setState`. Everything is just a sequence. 61 | 62 | If you're new to RxJS, this may look confusing at first, but — like React — if you give it a try you may be surprised by how simple and *fun* reactive programming can be. 63 | 64 | **TODO: React Router example. See [this comment](https://github.com/gaearon/redux/issues/227#issuecomment-119237073) for now.** 65 | 66 | `render()` is an optional second parameter which maps child props to a React element (vdom). This parameter can also be a React Component class — or, if you omit it entirely, a higher-order component is returned. See `createRxComponent()` of [react-rx-component](https://github.com/acdlite/react-rx-component) for more details. (This function is a wrapper around that library's `createRxComponent()`.) 67 | 68 | Not that unlike Redux's built-in Connector, the resulting component does not have a `select` prop. It is superseded by the `selectState` function described above. Internally, `shouldComponentUpdate()` is still used for performance. 69 | 70 | **NOTE** `createConnector()` is a wrapper around [react-rx-component](https://github.com/acdlite/react-rx-component). Check out that project for more information on how to use RxJS to construct smart components. 71 | 72 | ### `bindActionCreators(actionCreators, dispatch$)` 73 | 74 | This is the same, except `dispatch$` can be either a dispatch function *or* a sequence of dispatch functions. See previous section for context. 75 | 76 | ### `observableMiddleware` 77 | 78 | The middleware works on RxJS observables, and Flux Standard Actions whose payloads are observables. 79 | 80 | The default export is a middleware function. If it receives a promise, it will dispatch the resolved value of the promise. It will not dispatch anything if the promise rejects. 81 | 82 | If it receives an Flux Standard Action whose `payload` is an observable, it will 83 | 84 | - dispatch a new FSA for each value in the sequence. 85 | - dispatch an FSA on error. 86 | 87 | The middleware does not subscribe to the passed observable. Rather, it returns the observable to the caller, which is responsible for creating a subscription. Dispatches occur as a side effect (implemented using `doOnNext()` and `doOnError()`). 88 | 89 | #### Example 90 | 91 | ```js 92 | // fromEvent() used just for illustration. More likely, if you're using React, 93 | // you should use something rx-react's FuncSubject 94 | // https://github.com/fdecampredon/rx-react#funcsubject 95 | const buttonClickStream = Observable.fromEvent(button, 'click'); 96 | 97 | // Stream of new todos, with debouncing 98 | const newTodoStream = buttonClickStream 99 | .debounce(100) 100 | .map(getTodoTextFromInput); 101 | 102 | // Dispatch new todos whenever they're created 103 | dispatch(newTodoStream).subscribe(); 104 | ``` 105 | 106 | ### `observableFromStore(store)` 107 | 108 | Creates an observable sequence of states from a Redux store. 109 | 110 | This is a great way to react to state changes outside of the React render cycle. See [this discussion](https://github.com/gaearon/redux/issues/177#issuecomment-115389776) for an example. I'll update with a proper example once React Router 1.0 is released. 111 | 112 | Also, I'm not a Cycle.js user, but I imagine this is useful for integrating Redux with that library. 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-rx", 3 | "version": "0.5.0", 4 | "description": "RxJS utilities for Redux", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "make test", 8 | "prepublish": "make clean build" 9 | }, 10 | "keywords": [ 11 | "redux", 12 | "observable", 13 | "middleware", 14 | "redux-middleware", 15 | "rxjs", 16 | "rx", 17 | "reactive", 18 | "fsa", 19 | "flux" 20 | ], 21 | "author": "Andrew Clark ", 22 | "homepage": "https://github.com/acdlite/redux-rx", 23 | "license": "MIT", 24 | "repository": "acdlite/redux-rx", 25 | "bugs": { 26 | "url": "https://github.com/acdlite/redux-rx/issues" 27 | }, 28 | "devDependencies": { 29 | "babel": "^5.6.14", 30 | "babel-core": "^5.6.15", 31 | "babel-eslint": "^3.1.20", 32 | "chai": "^3.0.0", 33 | "chai-as-promised": "^5.1.0", 34 | "eslint": "^0.24.0", 35 | "eslint-config-airbnb": "0.0.6", 36 | "eslint-plugin-react": "^2.6.4", 37 | "fbjs": "0.3.2", 38 | "jsdom": "^5.6.0", 39 | "mocha": "^2.2.5", 40 | "mocha-jsdom": "^1.0.0", 41 | "react": "^0.14.0", 42 | "react-addons-test-utils": "0.14.0", 43 | "redux": "^3.0.3", 44 | "rx": "^4.0.6", 45 | "sinon": "^1.15.4" 46 | }, 47 | "dependencies": { 48 | "flux-standard-action": "^0.6.0", 49 | "react-redux": "^4.0.0", 50 | "react-rx-component": "^0.5.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /react.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 6 | 7 | var _libCreateConnector = require('./lib/createConnector'); 8 | 9 | var _libCreateConnector2 = _interopRequireDefault(_libCreateConnector); 10 | 11 | exports.createConnector = _libCreateConnector2['default']; -------------------------------------------------------------------------------- /src/__tests__/createConnector-test.js: -------------------------------------------------------------------------------- 1 | import createConnector from '../createConnector'; 2 | import { createStore, combineReducers } from 'redux'; 3 | import { bindActionCreators } from '../'; 4 | import { Provider } from 'react-redux'; 5 | import { Observable } from 'rx'; 6 | import { funcSubject } from 'react-rx-component'; 7 | import jsdom from './jsdom'; 8 | import React from 'react'; 9 | import TestUtils from 'react-addons-test-utils'; 10 | 11 | const { combineLatest } = Observable; 12 | 13 | const actionCreators = { 14 | addTodo(text) { 15 | return { type: 'ADD_TODO', payload: text }; 16 | } 17 | }; 18 | 19 | function todoReducer(state = { todos: [] }, action) { 20 | return action.type === 'ADD_TODO' 21 | ? { ...state, todos: [...state.todos, action.payload] } 22 | : state; 23 | } 24 | 25 | describe('createConnector()', () => { 26 | jsdom(); 27 | 28 | it('creates a Connector-like component using RxJS sequences', () => { 29 | const store = createStore(combineReducers({ todos: todoReducer })); 30 | 31 | // External source 32 | const increment$ = funcSubject(); 33 | 34 | const TodoConnector = createConnector((props$, state$, dispatch$) => { 35 | const actionCreators$ = bindActionCreators(actionCreators, dispatch$); 36 | const selectedState$ = state$.map(s => s.todos); 37 | const count$ = increment$.startWith(0).scan(t => t + 1); 38 | 39 | return combineLatest( 40 | props$, selectedState$, actionCreators$, count$, 41 | (props, selectedState, { addTodo }, count) => ({ 42 | ...props, 43 | ...selectedState, 44 | addTodo, 45 | count 46 | })); 47 | }, props =>
); 48 | 49 | const tree = TestUtils.renderIntoDocument( 50 | 51 | 52 | 53 | ); 54 | 55 | const div = TestUtils.findRenderedDOMComponentWithTag(tree, 'div'); 56 | expect(div.props.todos).to.deep.equal([]); 57 | div.props.addTodo('Use Redux'); 58 | expect(div.props.todos).to.deep.equal([ 'Use Redux' ]); 59 | div.props.addTodo('Use RxJS'); 60 | expect(div.props.todos).to.deep.equal([ 'Use Redux', 'Use RxJS' ]); 61 | increment$(); 62 | increment$(); 63 | increment$(); 64 | expect(div.props.count).to.equal(3); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/__tests__/init.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | 4 | global.expect = chai.expect; 5 | chai.use(chaiAsPromised); 6 | -------------------------------------------------------------------------------- /src/__tests__/jsdom.js: -------------------------------------------------------------------------------- 1 | import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment'; 2 | import jsdom from 'mocha-jsdom'; 3 | 4 | export default function jsdomReact() { 5 | jsdom(); 6 | ExecutionEnvironment.canUseDOM = true; 7 | } 8 | -------------------------------------------------------------------------------- /src/__tests__/observableFromStore-test.js: -------------------------------------------------------------------------------- 1 | import { observableFromStore } from '../'; 2 | import { spy } from 'sinon'; 3 | 4 | // Mock createStore 5 | function createStore(unsubscribeSpy) { 6 | let state; 7 | let subscribers = []; 8 | 9 | return { 10 | getState: () => state, 11 | dispatch(action) { 12 | // Just overwrite existing state 13 | state = action; 14 | subscribers.forEach(s => s()); 15 | }, 16 | subscribe(subscriber) { 17 | subscribers.push(subscriber); 18 | return unsubscribeSpy; 19 | } 20 | }; 21 | } 22 | 23 | describe('observableFromStore()', () => { 24 | let store; 25 | let unsubscribe; 26 | 27 | beforeEach(() => { 28 | unsubscribe = spy(); 29 | store = createStore(unsubscribe); 30 | }); 31 | 32 | it('returns an observable sequence of store states', () => { 33 | const next = spy(); 34 | observableFromStore(store).subscribe(next); 35 | 36 | store.dispatch(1); 37 | store.dispatch(2); 38 | store.dispatch(3); 39 | 40 | expect(next.args.map(args => args[0])).to.deep.equal([ 1, 2, 3 ]); 41 | }); 42 | 43 | it('unsubscribes on complete', () => { 44 | const next = spy(); 45 | const subscription = observableFromStore(store).subscribe(next); 46 | 47 | store.dispatch(1); 48 | store.dispatch(2); 49 | store.dispatch(3); 50 | 51 | expect(next.callCount).to.equal(3); 52 | subscription.dispose(); 53 | expect(unsubscribe.calledOnce).to.be.true; 54 | 55 | store.dispatch(4); 56 | store.dispatch(5); 57 | store.dispatch(6); 58 | 59 | expect(next.callCount).to.equal(3); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/__tests__/observableMiddleware-test.js: -------------------------------------------------------------------------------- 1 | import { observableMiddleware } from '../'; 2 | import { spy } from 'sinon'; 3 | import { Observable } from 'rx'; 4 | 5 | function noop() {} 6 | 7 | describe('observableMiddleware', () => { 8 | let baseDispatch; 9 | let dispatch; 10 | let error; 11 | let foobar; 12 | let stream; 13 | 14 | beforeEach(() => { 15 | baseDispatch = spy(); 16 | dispatch = observableMiddleware()(baseDispatch); 17 | error = new Error(); 18 | foobar = { foo: 'bar' }; 19 | stream = Observable.concat( 20 | Observable.of(1, 2), 21 | Observable.throw(error), 22 | Observable.of(3) 23 | ); 24 | }); 25 | 26 | it('handles Flux standard actions', async () => { 27 | await dispatch({ 28 | type: 'ACTION_TYPE', 29 | payload: stream 30 | }).toPromise().catch(noop); 31 | 32 | expect(baseDispatch.args.map(args => args[0])).to.deep.equal([ 33 | { type: 'ACTION_TYPE', payload: 1 }, 34 | { type: 'ACTION_TYPE', payload: 2 }, 35 | { type: 'ACTION_TYPE', payload: error, error: true } 36 | ]); 37 | }); 38 | 39 | it('handles observables', async () => { 40 | await dispatch(stream).toPromise().catch(noop); 41 | expect(baseDispatch.args.map(args => args[0])).to.deep.equal([ 1, 2 ]); 42 | }); 43 | 44 | it('ignores non-observables', async() => { 45 | dispatch(foobar); 46 | expect(baseDispatch.firstCall.args[0]).to.equal(foobar); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/bindActionCreators.js: -------------------------------------------------------------------------------- 1 | import isObservable from './isObservable'; 2 | import { bindActionCreators as bac } from 'redux'; 3 | 4 | export default function bindActionCreators(actionCreators, $dispatch) { 5 | return isObservable($dispatch) 6 | ? $dispatch.map(dispatch => bac(actionCreators, dispatch)) 7 | : bac(actionCreators, $dispatch); 8 | } 9 | -------------------------------------------------------------------------------- /src/createConnector.js: -------------------------------------------------------------------------------- 1 | import { createRxComponent } from 'react-rx-component'; 2 | import { observableFromStore } from './'; 3 | import { PropTypes } from 'react'; 4 | 5 | export default function createConnector(selectState, render) { 6 | const Connector = createRxComponent( 7 | (props$, context$) => selectState( 8 | props$, 9 | context$.flatMap( 10 | c => observableFromStore(c.store).startWith(c.store.getState()) 11 | ), 12 | context$.map(c => c.store.dispatch), 13 | context$ 14 | ), 15 | render 16 | ); 17 | Connector.displayName = 'Connector'; 18 | Connector.contextTypes = { store: PropTypes.object }; 19 | return Connector; 20 | } 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import observableFromStore from './observableFromStore'; 2 | import observableMiddleware from './observableMiddleware'; 3 | import bindActionCreators from './bindActionCreators'; 4 | 5 | export { 6 | observableFromStore, 7 | observableMiddleware, 8 | bindActionCreators 9 | }; 10 | -------------------------------------------------------------------------------- /src/isObservable.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rx'; 2 | 3 | export default function isObservable(val) { 4 | return val instanceof Observable; 5 | } 6 | -------------------------------------------------------------------------------- /src/observableFromStore.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rx'; 2 | 3 | export default function observableFromStore(store) { 4 | return Observable.create(observer => 5 | store.subscribe(() => observer.onNext(store.getState())) 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/observableMiddleware.js: -------------------------------------------------------------------------------- 1 | import { isFSA } from 'flux-standard-action'; 2 | import isObservable from './isObservable'; 3 | 4 | export default function observableMiddleware() { 5 | return next => action => { 6 | if (!isFSA(action)) { 7 | return isObservable(action) 8 | ? action.doOnNext(next) 9 | : next(action); 10 | } 11 | 12 | return isObservable(action.payload) 13 | ? action.payload 14 | .doOnNext(x => next({ ...action, payload: x })) 15 | .doOnError(e => next({ ...action, payload: e, error: true })) 16 | : next(action); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/react.js: -------------------------------------------------------------------------------- 1 | import createConnector from './lib/createConnector'; 2 | 3 | export { createConnector }; 4 | --------------------------------------------------------------------------------