├── .babelrc ├── .editorconfig ├── .env ├── .eslintrc.json ├── .gitignore ├── Procfile ├── README.md ├── package-lock.json ├── package.json ├── server ├── dev-server.js └── server.js ├── src ├── actions │ └── counterActions.js ├── components │ ├── App.jsx │ ├── Counter.jsx │ └── Counter.spec.js ├── index.html ├── main.jsx ├── reducers │ ├── CounterReducer.js │ ├── CounterReducer.spec.js │ └── index.js └── state │ ├── RxState.js │ └── RxState.spec.js ├── tests └── setup │ └── setupBrowser.js └── webpack ├── webpack.config.js ├── webpack.dev.config.js └── webpack.prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "settings": { 5 | "import/resolver": { 6 | "webpack": { "config": "./webpack/webpack.prod.config.js" } 7 | } 8 | }, 9 | "env": { 10 | "browser": true, 11 | "node": true 12 | }, 13 | "rules": { 14 | "quotes": ["error", "double"], 15 | "no-unused-vars": ["error", { "vars": "local", "args": "after-used", "argsIgnorePattern": "^_" }] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # connect-rxjs-to-react 2 | 3 | Simple way to connect [rxjs](https://www.npmjs.com/package/rxjs) to React component in Redux 4 | style... but without dispatch and constants. 5 | 6 | ```jsx 7 | // CounterActions.js 8 | const counterActions = createActions(["increment", "decrement", "reset"]) 9 | 10 | // CounterReducer.js 11 | const CounterReducer$ = Rx.Observable.of(() => initialState) 12 | .merge( 13 | counterActions.increment.map(payload => state => state + payload), 14 | counterActions.decrement.map(payload => state => state - payload), 15 | counterActions.reset.map(_payload => _state => initialState), 16 | ); 17 | 18 | // Counter.jsx 19 | const Counter = ({ counter, increment, decrement, reset }) => ( 20 | ... 21 | ); 22 | 23 | connect(({ counter }) => ({ counter }), counterActions)(Counter); 24 | ``` 25 | 26 | Read more about RxJS with React: [http://michalzalecki.com/use-rxjs-with-react](http://michalzalecki.com/use-rxjs-with-react) 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "connect-rxjs-to-react", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node server/server.js", 8 | "start:dev": "node server/dev-server", 9 | "start:dashboard": "webpack-dashboard node server/dev-server", 10 | "postinstall": "npm run build", 11 | "build": "webpack --config webpack/webpack.prod.config.js -p", 12 | "clean": "rm -rf build", 13 | "lint": "eslint src --ext .js*", 14 | "test": "ava", 15 | "test:watch": "ava --watch" 16 | }, 17 | "author": "Michał Załęcki ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "babel": "^6.5.2", 21 | "babel-core": "^6.13.2", 22 | "babel-loader": "^6.2.4", 23 | "babel-preset-es2015": "^6.13.2", 24 | "babel-preset-react": "^6.11.1", 25 | "babel-preset-stage-0": "^6.5.0", 26 | "compression": "^1.6.2", 27 | "css-loader": "^0.25.0", 28 | "dotenv": "^2.0.0", 29 | "express": "^4.14.0", 30 | "extract-text-webpack-plugin": "^1.0.1", 31 | "html-webpack-plugin": "^2.22.0", 32 | "postcss": "^5.1.1", 33 | "postcss-browser-reporter": "^0.5.0", 34 | "postcss-cssnext": "^2.7.0", 35 | "postcss-import": "^8.1.2", 36 | "postcss-loader": "^0.13.0", 37 | "postcss-nested": "^1.0.0", 38 | "postcss-reporter": "^1.4.1", 39 | "postcss-url": "^5.1.2", 40 | "prop-types": "^15.5.10", 41 | "react": "^15.3.0", 42 | "react-dom": "^15.5.4", 43 | "react-hot-loader": "^1.3.0", 44 | "rxjs": "^5.0.0-beta.12", 45 | "style-loader": "^0.13.1", 46 | "webpack": "^1.13.1" 47 | }, 48 | "devDependencies": { 49 | "ava": "^0.16.0", 50 | "babel-eslint": "^6.1.2", 51 | "enzyme": "^2.4.1", 52 | "eslint": "^3.2.2", 53 | "eslint-config-airbnb": "^11.0.0", 54 | "eslint-import-resolver-webpack": "^0.5.1", 55 | "eslint-plugin-import": "^1.12.0", 56 | "eslint-plugin-jsx-a11y": "^2.0.1", 57 | "eslint-plugin-react": "^6.0.0", 58 | "jsdom": "^9.5.0", 59 | "ramda": "^0.22.1", 60 | "react-test-renderer": "^15.5.4", 61 | "sinon": "^1.17.5", 62 | "webpack-dashboard": "^0.1.6", 63 | "webpack-dev-middleware": "^1.6.1", 64 | "webpack-dev-server": "^1.14.1", 65 | "webpack-hot-middleware": "^2.12.2" 66 | }, 67 | "ava": { 68 | "files": [ 69 | "src/**/*.spec.js" 70 | ], 71 | "require": [ 72 | "babel-register", 73 | "./tests/setup/setupBrowser.js" 74 | ], 75 | "babel": "inherit", 76 | "timeout": "60s", 77 | "verbose": true 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/dev-server.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const express = require("express"); 3 | const webpack = require("webpack"); 4 | const webpackDevMiddleware = require("webpack-dev-middleware"); 5 | const webpackHotMiddleware = require("webpack-hot-middleware"); 6 | const config = require("../webpack/webpack.dev.config"); 7 | const DashboardPlugin = require("webpack-dashboard/plugin"); 8 | 9 | const PORT = process.env.PORT || 8080; 10 | 11 | const app = express(); 12 | 13 | const compiler = webpack(config); 14 | 15 | compiler.apply(new DashboardPlugin()); 16 | 17 | const middleware = webpackDevMiddleware(compiler, { 18 | contentBase: "build", 19 | stats: { colors: true }, 20 | }); 21 | 22 | app.use(middleware); 23 | app.use(webpackHotMiddleware(compiler)); 24 | 25 | app.get("*", (req, res) => { 26 | res.write(middleware.fileSystem.readFileSync(path.resolve("build/index.html"))); 27 | res.end(); 28 | }); 29 | 30 | const listener = app.listen(PORT, () => { 31 | console.log("express started at http://localhost:%d", listener.address().port); 32 | }); 33 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const compression = require("compression"); 3 | const path = require("path"); 4 | 5 | const app = express(); 6 | 7 | app.set("x-powered-by", false); 8 | 9 | app.use(compression()); 10 | app.use(express.static("build", { 11 | // etag: false 12 | })); 13 | 14 | app.get("*", (req, res) => { 15 | res.sendFile(path.resolve("build/index.html")); 16 | }); 17 | 18 | const listener = app.listen(process.env.PORT || 8080, () => { 19 | console.log("express started at port %d", listener.address().port); 20 | }); 21 | -------------------------------------------------------------------------------- /src/actions/counterActions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from "../state/RxState"; 2 | 3 | export default createActions(["increment", "decrement", "reset"]); 4 | -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Counter from "./Counter"; 3 | 4 | class App extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { 9 | showSecondCounter: false, 10 | }; 11 | } 12 | 13 | componentDidMount() { 14 | setTimeout(() => { 15 | this.setState({ showSecondCounter: true }); 16 | }, 5000); 17 | } 18 | 19 | render() { 20 | return ( 21 |
22 | 23 | {this.state.showSecondCounter ? ( 24 |
25 |

That one is connected as well and recived current state in props

26 | 27 |
28 | ) : null} 29 |
30 | ); 31 | } 32 | } 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /src/components/Counter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "../state/RxState"; 4 | import counterActions from "../actions/counterActions"; 5 | 6 | export const Counter = ({ counter, increment, decrement, reset }) => ( 7 |
8 |

{counter}

9 |
10 | 11 | 12 | 13 | 14 | 15 |
16 | ); 17 | 18 | Counter.propTypes = { 19 | counter: PropTypes.number.isRequired, 20 | increment: PropTypes.func.isRequired, 21 | decrement: PropTypes.func.isRequired, 22 | reset: PropTypes.func.isRequired, 23 | }; 24 | 25 | export default connect(({ counter }) => ({ counter }), counterActions)(Counter); 26 | -------------------------------------------------------------------------------- /src/components/Counter.spec.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import test from "ava"; 3 | import sinon from "sinon"; 4 | import { shallow } from "enzyme"; 5 | import { Counter } from "./Counter"; 6 | 7 | test("displays counter", (t) => { 8 | const increment = sinon.spy(); 9 | const decrement = sinon.spy(); 10 | const reset = sinon.spy(); 11 | const tree = shallow( 12 | 18 | ); 19 | t.is(tree.find("h1").text(), "123"); 20 | }); 21 | 22 | test("calls passed actions", (t) => { 23 | const increment = sinon.spy(); 24 | const decrement = sinon.spy(); 25 | const reset = sinon.spy(); 26 | const tree = shallow( 27 | 33 | ); 34 | tree.find("#increment").simulate("click"); 35 | t.true(increment.calledWith(1)); 36 | tree.find("#increment10").simulate("click"); 37 | t.true(increment.calledWith(10)); 38 | tree.find("#reset").simulate("click"); 39 | t.true(reset.called); 40 | tree.find("#decrement").simulate("click"); 41 | t.true(decrement.calledWith(1)); 42 | tree.find("#decrement10").simulate("click"); 43 | t.true(decrement.calledWith(10)); 44 | }); 45 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider, createState } from "./state/RxState"; 4 | import App from "./components/App"; 5 | import reducer$ from "./reducers"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root"), 12 | ); 13 | -------------------------------------------------------------------------------- /src/reducers/CounterReducer.js: -------------------------------------------------------------------------------- 1 | import Rx from "rxjs"; 2 | import counterActions from "../actions/counterActions"; 3 | 4 | const initialState = 0; 5 | 6 | const CounterReducer$ = Rx.Observable.of(() => initialState) 7 | .merge( 8 | counterActions.increment.map(payload => state => state + payload), 9 | counterActions.decrement.map(payload => state => state - payload), 10 | counterActions.reset.map(_payload => _state => initialState), 11 | ); 12 | 13 | export default CounterReducer$; 14 | -------------------------------------------------------------------------------- /src/reducers/CounterReducer.spec.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { pipe } from "ramda"; 3 | import counterActions from "../actions/counterActions"; 4 | import CounterReducer$ from "./CounterReducer"; 5 | 6 | test("handles increment, decrement and reset actions", (t) => { 7 | CounterReducer$.take(5).toArray().subscribe((fns) => { 8 | t.is(pipe(...fns)(), 9); 9 | }); 10 | 11 | counterActions.increment.next(1); 12 | counterActions.reset.next(); 13 | counterActions.increment.next(10); 14 | counterActions.decrement.next(1); 15 | }); 16 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import Rx from "rxjs"; 2 | import CounterReducer$ from "../reducers/CounterReducer"; 3 | 4 | const reducer$ = Rx.Observable.merge( 5 | CounterReducer$.map(reducer => ["counter", reducer]), 6 | ); 7 | 8 | export default reducer$; 9 | -------------------------------------------------------------------------------- /src/state/RxState.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Rx from "rxjs"; 4 | 5 | export function createAction() { 6 | return new Rx.Subject(); 7 | } 8 | 9 | export function createActions(actionNames) { 10 | return actionNames.reduce((akk, name) => ({ ...akk, [name]: createAction() }), {}); 11 | } 12 | 13 | export function createState(reducer$, initialState$ = Rx.Observable.of({})) { 14 | return initialState$ 15 | .merge(reducer$) 16 | .scan((state, [scope, reducer]) => 17 | ({ ...state, [scope]: reducer(state[scope]) })) 18 | .publishReplay(1) 19 | .refCount(); 20 | } 21 | 22 | export function connect(selector = state => state, actionSubjects) { 23 | const actions = Object.keys(actionSubjects) 24 | .reduce((akk, key) => ({ ...akk, [key]: value => actionSubjects[key].next(value) }), {}); 25 | 26 | return function wrapWithConnect(WrappedComponent) { 27 | return class Connect extends Component { 28 | static contextTypes = { 29 | state$: PropTypes.object.isRequired, 30 | }; 31 | 32 | componentWillMount() { 33 | this.subscription = this.context.state$.map(selector).subscribe(::this.setState); 34 | } 35 | 36 | componentWillUnmount() { 37 | this.subscription.unsubscribe(); 38 | } 39 | 40 | render() { 41 | return ( 42 | 43 | ); 44 | } 45 | }; 46 | }; 47 | } 48 | 49 | export class Provider extends Component { 50 | static propTypes = { 51 | state$: PropTypes.object.isRequired, 52 | }; 53 | 54 | static childContextTypes = { 55 | state$: PropTypes.object.isRequired, 56 | }; 57 | 58 | getChildContext() { 59 | return { state$: this.props.state$ }; 60 | } 61 | 62 | render() { 63 | return this.props.children; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/state/RxState.spec.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { mount } from "enzyme"; 3 | import Rx from "rxjs"; 4 | import React from "react"; 5 | 6 | import { 7 | createAction, 8 | createActions, 9 | createState, 10 | Provider, 11 | connect, 12 | } from "./RxState"; 13 | 14 | test("createAction creates new Subject instance", (t) => { 15 | const action$ = createAction(); 16 | const anotherAction$ = createAction(); 17 | 18 | t.true(action$ instanceof Rx.Subject); 19 | t.not(action$, anotherAction$); 20 | }); 21 | 22 | test("createActions creates new dict with actions", (t) => { 23 | const actions = createActions(["add$", "decrement$"]); 24 | 25 | t.deepEqual(actions.add$, new Rx.Subject()); 26 | t.deepEqual(actions.decrement$, new Rx.Subject()); 27 | t.is(actions.count$, undefined); 28 | }); 29 | 30 | test("createState creates reactive state using scoped reducers", (t) => { 31 | const add$ = new Rx.Subject(); 32 | const counterReducer$ = add$.map(payload => state => state + payload); 33 | const rootReducer$ = counterReducer$.map(counter => ["counter", counter]); 34 | const state$ = createState(rootReducer$, Rx.Observable.of({ counter: 10 })); 35 | 36 | t.plan(1); 37 | 38 | add$.next(1); // No subscribers yet 39 | 40 | state$.toArray().subscribe((results) => { 41 | t.deepEqual(results, [{ counter: 10 }, { counter: 12 }]); 42 | }); 43 | 44 | add$.next(2); 45 | add$.complete(); 46 | }); 47 | 48 | test("connect maps state to props in Provider context", (t) => { 49 | const add$ = new Rx.Subject(); 50 | const counterReducer$ = add$.map(payload => state => state + payload); 51 | const rootReducer$ = counterReducer$.map(counter => ["counter", counter]); 52 | const state$ = createState(rootReducer$, Rx.Observable.of({ counter: 10 })); 53 | 54 | const Counter = ({ counter, add }) => ( 55 |
56 |

{counter}

57 | 58 |
59 | ); 60 | 61 | const ConnectedCounter = connect(state => ({ counter: state.counter }), { add: add$ })(Counter); 62 | 63 | const tree = mount( 64 | 65 | 66 | 67 | ); 68 | 69 | t.is(tree.find("h1").text(), "10"); 70 | tree.find("button").simulate("click"); 71 | t.is(tree.find("h1").text(), "11"); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/setup/setupBrowser.js: -------------------------------------------------------------------------------- 1 | global.document = require("jsdom").jsdom(""); 2 | global.window = document.defaultView; 3 | global.navigator = window.navigator; 4 | -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: 0 */ 2 | 3 | require("dotenv").config({ silent: true }); 4 | 5 | const webpack = require("webpack"); 6 | const path = require("path"); 7 | const HTMLWebpackPlugin = require("html-webpack-plugin"); 8 | 9 | const config = { 10 | entry: [ 11 | path.resolve("src/main.jsx"), 12 | ], 13 | 14 | output: { 15 | path: path.resolve("build"), 16 | filename: "app.[hash].js", 17 | publicPath: "/", 18 | }, 19 | 20 | resolve: { 21 | extensions: ["", ".js", ".jsx"], 22 | alias: { 23 | src: path.resolve(__dirname, "../src"), 24 | }, 25 | }, 26 | 27 | plugins: [ 28 | new webpack.EnvironmentPlugin(["NODE_ENV"]), 29 | new HTMLWebpackPlugin({ 30 | template: path.resolve("src/index.html"), 31 | minify: { collapseWhitespace: true }, 32 | }), 33 | ], 34 | 35 | module: { 36 | loaders: [ 37 | { test: /\.jsx?$/, exclude: /node_modules/, loader: "babel" }, 38 | ], 39 | }, 40 | 41 | postcss() { 42 | return [ 43 | require("postcss-import")({ addDependencyTo: webpack }), 44 | require("postcss-url")(), 45 | require("postcss-cssnext")(), 46 | require("postcss-nested")(), 47 | // add your "plugins" here 48 | require("postcss-browser-reporter")(), 49 | require("postcss-reporter")(), 50 | ]; 51 | }, 52 | }; 53 | 54 | module.exports = config; 55 | -------------------------------------------------------------------------------- /webpack/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const config = require("./webpack.config"); 3 | 4 | const devConfig = { 5 | devtool: "eval", 6 | 7 | entry: [ 8 | "webpack-hot-middleware/client?reload=true", 9 | ].concat(config.entry), 10 | 11 | output: config.output, 12 | 13 | resolve: config.resolve, 14 | 15 | plugins: [ 16 | new webpack.HotModuleReplacementPlugin(), 17 | new webpack.NoErrorsPlugin(), 18 | ].concat(config.plugins), 19 | 20 | module: { 21 | loaders: [ 22 | { test: /\.jsx?$/, exclude: /node_modules/, loader: "react-hot" }, 23 | { test: /\.css$/, loader: "style!css?sourceMap&localIdentName=[local]_[hash:base64:4]!postcss" }, 24 | ].concat(config.module.loaders), 25 | }, 26 | 27 | postcss: config.postcss, 28 | }; 29 | 30 | module.exports = devConfig; 31 | -------------------------------------------------------------------------------- /webpack/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const config = require("./webpack.config"); 2 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 3 | 4 | const prodConfig = { 5 | devtool: "source-map", 6 | 7 | entry: config.entry, 8 | 9 | resolve: config.resolve, 10 | 11 | output: config.output, 12 | 13 | plugins: [ 14 | new ExtractTextPlugin("styles.css"), 15 | ].concat(config.plugins), 16 | 17 | module: { 18 | loaders: [ 19 | { test: /\.css$/, 20 | loader: ExtractTextPlugin.extract("style", "css?sourceMap!postcss"), 21 | }, 22 | ].concat(config.module.loaders), 23 | }, 24 | 25 | postcss: config.postcss, 26 | }; 27 | 28 | module.exports = prodConfig; 29 | --------------------------------------------------------------------------------