├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .npmignore ├── .travis.yml ├── changelog.md ├── package.json ├── readme.md ├── rollup.config.js ├── setupJest.js ├── src ├── PropsHelper.ts ├── __snapshots__ │ └── combineProps.test.ts.snap ├── combineLatestObj.ts ├── combineProps.test.ts ├── combineProps.ts ├── connect.test.tsx ├── connect.ts ├── index.ts ├── useRxContainer.test.tsx └── useRxController.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-env", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended", 6 | "prettier/@typescript-eslint" 7 | ], 8 | "env": { 9 | "node": true, 10 | "browser": true, 11 | "es6": true 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-function-return-type": 0, 15 | "@typescript-eslint/explicit-member-accessibility": 0, 16 | "@typescript-eslint/interface-name-prefix": 0, 17 | "@typescript-eslint/no-namespace": 1, 18 | "@typescript-eslint/no-inferrable-types": 0, 19 | "@typescript-eslint/ban-ts-ignore": 0 20 | }, 21 | "overrides": [ 22 | { 23 | "files": [ 24 | "**/*.test.*" 25 | ], 26 | "env": { 27 | "jest": true 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules 3 | /.babelrc 4 | /coverage 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 8 5 | 6 | cache: yarn 7 | 8 | install: 9 | - yarn install 10 | 11 | script: 12 | - yarn run lint && yarn run cover 13 | 14 | after_success: 15 | - ./node_modules/.bin/codecov 16 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.10.0 (2022-09-08) 4 | 5 | - upgrade react to version 17, and rxjs to version 7 6 | 7 | ## 0.9.0 (2019-08-28) 8 | 9 | - `useRxContainer` hook, as alternative to `connect` function 10 | - *breaking* restricting what can be accessed from `controller`, limiting it to only `props` (instead of wrapper component instance) 11 | 12 | ## 0.8.0 (2019-08-27) 13 | 14 | - better type coverage 15 | - *breaking* removed `combineProps` feature automatically removing `$` at end of variable, in exchange - now result type is correctly inferred from argument types 16 | 17 | ## 0.7.0 (2018-01-28) 18 | 19 | - TypeScript 20 | - *breaking* remove deprecated `createContainer` 21 | 22 | ## 0.6.2 (2018-09-04) 23 | 24 | - Babel 7 25 | 26 | ## 0.6.1 (2018-06-21) 27 | 28 | - hoist-non-react-statics (#8) (Pavlos Vinieratos) 29 | - deps updates 30 | 31 | ## 0.6.0 (2018-04-26) 32 | 33 | - Remove usage of lifecycle hooks deprecated in react v 16.3 (https://reactjs.org/blog/2018/03/27/update-on-async-rendering.html#adding-event-listeners-or-subscriptions) 34 | - rxjs v6 35 | 36 | ## 0.5.0 (2017-10-28) 37 | 38 | Start using RxJS pipeable operators. 39 | 40 | ## 0.4.1 (2017-10-07) 41 | 42 | Update react peer dependency. Dev tooling updates 43 | 44 | ## 0.4.0 (2017-09-18) 45 | 46 | Start using rollup to bundle library for distribution 47 | 48 | ## 0.3.0 (2017-08-06) 49 | 50 | Introduce HoC connecting RxJS logic to React Component. 51 | In comparison `createContainer` in previous versions this should provide: 52 | - better testing possibilities for logic by allowing to separate it from view 53 | - React Components are easier to compose 54 | 55 | As a drawback comparing to previous approach - it will not wait for data before render, 56 | but I think it is better to handle this kind of logic separately. 57 | 58 | ### Features 59 | 60 | - `connect` function for creating HoC component from react component and function Observable with properties 61 | - `combineProps` helper function to create properties observable from observables, observers and static props 62 | - refactor `createContainer` to use `combineProps` internally 63 | - deprecate `createContainer` in favor of creating HoC using newly introduced functions 64 | 65 | ### Bugfix 66 | 67 | - fix possible memory leak when rendering server-side 68 | 69 | ## 0.2.2 (2017-05-20) 70 | 71 | - move rxjs and prop-types to peerDependencies 72 | - update deps 73 | - switch to loose compilation mode 74 | 75 | ## 0.2.1 (2017-04-09) 76 | 77 | - use RxJS in modular way(reducing resulting bundle size) 78 | 79 | ## 0.2.0 (2016-12-20) 80 | 81 | Upgrade to RxJS v5 82 | 83 | ## 0.1.4 (2016-04-06) 84 | 85 | Bugfix, performance improvements, documentation 86 | 87 | ## 0.1.0 (2015-12-05) 88 | 89 | Initial release 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-react-container", 3 | "version": "0.10.0", 4 | "author": "Bogdan Savluk ", 5 | "description": "Provides HoC component, and utilities to connect RxJS logic to React Component.", 6 | "keywords": [ 7 | "react", 8 | "rxjs", 9 | "container", 10 | "isomorphic" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/zxbodya/rx-react-container.git" 15 | }, 16 | "license": "MIT", 17 | "engines": { 18 | "node": ">=8.9.0" 19 | }, 20 | "main": "dist/index.js", 21 | "module": "dist/rx-react-container.esm.js", 22 | "typings": "dist/index.d.ts", 23 | "sideEffects": false, 24 | "scripts": { 25 | "lint": "eslint './**/*.{ts,js,tsx,jsx}'", 26 | "cover": "jest --coverage", 27 | "test": "jest", 28 | "build": "rimraf dist && rollup -c", 29 | "watch": "rollup -c -w", 30 | "prepublish": "npm run build" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.6.0", 34 | "@babel/plugin-proposal-class-properties": "^7.5.5", 35 | "@babel/plugin-proposal-object-rest-spread": "^7.5.5", 36 | "@babel/preset-env": "^7.6.0", 37 | "@babel/preset-react": "^7.0.0", 38 | "@babel/preset-typescript": "^7.6.0", 39 | "@types/enzyme": "^3.10.3", 40 | "@types/hoist-non-react-statics": "^3.3.1", 41 | "@types/jest": "^24.0.18", 42 | "@types/react": "^17.0.49", 43 | "@typescript-eslint/eslint-plugin": "^2.3.0", 44 | "@typescript-eslint/parser": "^2.3.0", 45 | "codecov": "^3.6.1", 46 | "enzyme": "^3.11.0", 47 | "enzyme-adapter-react-17-updated": "^1.0.2", 48 | "eslint": "^6.4.0", 49 | "eslint-config-prettier": "^6.3.0", 50 | "eslint-plugin-prettier": "^3.1.1", 51 | "jest": "^24.9.0", 52 | "prettier": "^1.18.2", 53 | "raf": "^3.4.1", 54 | "react": "^17.0.2", 55 | "react-dom": "^17.0.2", 56 | "react-test-renderer": "17.0.2", 57 | "rimraf": "^3.0.2", 58 | "rollup": "^2.79.0", 59 | "rollup-plugin-typescript2": "^0.33.0", 60 | "rxjs": "^7.5.6", 61 | "temp-dir": "^2.0.0", 62 | "typescript": "^4.8.2" 63 | }, 64 | "peerDependencies": { 65 | "react": "^17.0.2", 66 | "rxjs": "^7.5.6", 67 | "tslib": "^2.4.0" 68 | }, 69 | "prettier": { 70 | "singleQuote": true, 71 | "trailingComma": "es5" 72 | }, 73 | "jest": { 74 | "setupFiles": [ 75 | "raf/polyfill", 76 | "./setupJest" 77 | ], 78 | "roots": [ 79 | "src" 80 | ] 81 | }, 82 | "dependencies": { 83 | "hoist-non-react-statics": "^3.3.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Rx React Container 2 | 3 | [![Build Status](https://travis-ci.org/zxbodya/rx-react-container.svg?branch=master)](https://travis-ci.org/zxbodya/rx-react-container) 4 | [![codecov.io](https://codecov.io/github/zxbodya/rx-react-container/coverage.svg?branch=master)](https://codecov.io/github/zxbodya/rx-react-container?branch=master) 5 | 6 | Helper utilities allowing to transparently connect RxJS logic to React Component. 7 | 8 | Works by wrapping React Component into container that: 9 | 10 | - provides access to props passed to it as observables (both individual, and combinations - see details below) 11 | - renders wrapped component with data form observable created in controller 12 | - provides utility to combine observables, observers and static props into one observable of props to be rendered 13 | 14 | If you are interested in history behind this - look at [gist about it](https://gist.github.com/zxbodya/20c63681d45a049df3fc). 15 | 16 | First project where it was used: [reactive-widgets](https://github.com/zxbodya/reactive-widgets) 17 | 18 | ### Installation 19 | 20 | `npm install rx-react-container --save` 21 | 22 | ### Usage 23 | 24 | Currently there are two ways of using it: 25 | - with high order components 26 | - with hooks 27 | 28 | High order components: 29 | 30 | ```ts 31 | const ContainerComponent = connect( 32 | controller: (propsHelper) => Observable 33 | )(WrappedComponent) 34 | ``` 35 | 36 | Hooks: 37 | 38 | *Warning: hooks version is very new, consider it experimental* 39 | 40 | ```ts 41 | const resultProps = useRxController( 42 | controller: (propsHelper) => Observable, 43 | props 44 | ); 45 | ``` 46 | 47 | In theory, both are equivalent, while hooks one is more compact/flexible. 48 | 49 | In both cases `controller` is function creating observable of properties to be rendered. 50 | 51 | `propsHelper` argument of it provides few helper methods to access props as observables: 52 | 53 | - `getProp(name)` - returning observable of distinct values of specified property 54 | - `getProps(...names)` - returning observable of distinct arrays of values for specified properties 55 | 56 | also there are fields with current properties(in some cases this is useful, but generally - better to use helper methods above): 57 | 58 | - `props$` - observable with current properties 59 | - `props` - getter to get current properties of wrapper component, or what was passed to hook when rendering 60 | 61 | To help combining various things into result observable, library also provides helper function to combine data into single observable: 62 | 63 | `combineProps(observables, observers, otherProps)` 64 | 65 | Where: 66 | 67 | - `observables` object with observables with data for component 68 | - `observers` object with observers to be passed as callbacks to component 69 | - `props` object with props to pass directly to component 70 | 71 | ### Example: 72 | 73 | ```JS 74 | import React from 'react'; 75 | import { render } from 'react-dom'; 76 | 77 | import { Subject, merge } from 'rxjs'; 78 | import { connect, combineProps, useRxController } from 'rx-react-container'; 79 | import { map, scan, switchMap, startWith } from 'rxjs/operators'; 80 | 81 | function App({ onMinus, onPlus, totalCount, step }) { 82 | return ( 83 |
84 | [{totalCount}] 85 | 86 |
87 | ); 88 | } 89 | 90 | function appController(container) { 91 | const onMinus$ = new Subject(); 92 | const onPlus$ = new Subject(); 93 | 94 | const click$ = merge( 95 | onMinus$.pipe(map(() => -1)), 96 | onPlus$.pipe(map(() => +1)) 97 | ); 98 | const step$ = container.getProp('step'); 99 | 100 | const totalCount$ = step$.pipe( 101 | switchMap(step => click$.pipe(map(v => v * step))), 102 | startWith(0), 103 | scan((acc, x) => acc + x, 0) 104 | ); 105 | 106 | return combineProps( 107 | { totalCount: totalCount$, step: step$ }, 108 | { onMinus: onMinus$, onPlus: onPlus$ } 109 | ); 110 | } 111 | 112 | const AppContainer = connect(appController)(App); 113 | 114 | // same thing with hooks 115 | function HookApp(props) { 116 | const state = useRxController(appController, props); 117 | if(!state) return null; 118 | const { onMinus, onPlus, totalCount, step } = state; 119 | return ( 120 |
121 | [{totalCount}] 122 | 123 |
124 | ); 125 | } 126 | 127 | const appElement = document.getElementById('app'); 128 | render(, appElement); 129 | 130 | ``` 131 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import pkg from './package.json'; 3 | import tempDir from 'temp-dir'; 4 | 5 | export default { 6 | input: 'src/index.ts', 7 | external: ['rxjs', 'prop-types', 'react', 'hoist-non-react-statics', 'tslib'], 8 | output: [ 9 | { file: pkg.main, sourcemap: true, format: 'cjs' }, 10 | { file: pkg.module, sourcemap: true, format: 'es' }, 11 | ], 12 | plugins: [typescript({ cacheRoot: `${tempDir}/.rpt2_cache` })], 13 | }; 14 | -------------------------------------------------------------------------------- /setupJest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // setup file 3 | const { configure } = require('enzyme'); 4 | const Adapter = require('enzyme-adapter-react-17-updated'); 5 | 6 | configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /src/PropsHelper.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { distinctUntilChanged, map } from 'rxjs/operators'; 3 | 4 | export type PropsHelper = { 5 | props: Props; 6 | props$: Observable; 7 | getProp(key: K): Observable; 8 | getProps>( 9 | ...keys: Keys 10 | ): Observable< 11 | { 12 | [Key in keyof Keys]: Keys[Key] extends keyof Props 13 | ? Props[Keys[Key]] 14 | : never; 15 | } 16 | >; 17 | }; 18 | 19 | export const createGetProp = (props$: Observable) => 20 | function getProp(key: K): Observable { 21 | return props$.pipe( 22 | map(props => props[key]), 23 | distinctUntilChanged() 24 | ); 25 | }; 26 | 27 | export const createGetProps = (props$: Observable) => 28 | function getProps>( 29 | ...keys: Keys 30 | ): Observable< 31 | { 32 | [Key in keyof Keys]: Keys[Key] extends keyof Props 33 | ? Props[Keys[Key]] 34 | : never; 35 | } 36 | > { 37 | const r = props$.pipe( 38 | distinctUntilChanged((p, q) => { 39 | for (let i = 0, l = keys.length; i < l; i += 1) { 40 | const name = keys[i]; 41 | if (p[name] !== q[name]) { 42 | return false; 43 | } 44 | } 45 | return true; 46 | }), 47 | map(props => keys.map(key => props[key])) 48 | ); 49 | return r as any; 50 | }; 51 | -------------------------------------------------------------------------------- /src/__snapshots__/combineProps.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`combineProps works correctly for empty arguments 1`] = ` 4 | Array [ 5 | Object {}, 6 | ] 7 | `; 8 | 9 | exports[`combineProps works correctly for no arguments 1`] = ` 10 | Array [ 11 | Object {}, 12 | ] 13 | `; 14 | 15 | exports[`combineProps works correctly for observables 1`] = ` 16 | Array [ 17 | Object { 18 | "a": 1, 19 | "b": 2, 20 | }, 21 | ] 22 | `; 23 | 24 | exports[`combineProps works correctly observers 1`] = ` 25 | Array [ 26 | Object { 27 | "a": [Function], 28 | }, 29 | ] 30 | `; 31 | 32 | exports[`combineProps works correctly when props are passed 1`] = ` 33 | Array [ 34 | Object { 35 | "a": 1, 36 | }, 37 | ] 38 | `; 39 | 40 | exports[`combineProps works for complete sample 1`] = ` 41 | Array [ 42 | Object { 43 | "a": 1, 44 | "b": 0, 45 | "c": 1, 46 | "d": 2, 47 | "onB": [Function], 48 | }, 49 | Object { 50 | "a": 1, 51 | "b": 1, 52 | "c": 1, 53 | "d": 2, 54 | "onB": [Function], 55 | }, 56 | ] 57 | `; 58 | -------------------------------------------------------------------------------- /src/combineLatestObj.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest, Observable } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | 4 | type AnyObservablesObject = { [k: string]: Observable }; 5 | 6 | export function combineLatestObj( 7 | obj: T 8 | ): Observable< 9 | { [k in keyof T]: T[k] extends Observable ? V : never } 10 | > { 11 | const sources = []; 12 | const keys: string[] = []; 13 | for (const key in obj) { 14 | /* istanbul ignore else */ 15 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 16 | keys.push(key); 17 | sources.push(obj[key]); 18 | } 19 | } 20 | return combineLatest(sources).pipe( 21 | map(args => { 22 | const combination: { [k: string]: any } = {}; 23 | for (let i = args.length - 1; i >= 0; i -= 1) { 24 | combination[keys[i]] = args[i]; 25 | } 26 | return combination as any; 27 | }) 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/combineProps.test.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, of } from 'rxjs'; 2 | 3 | import { take, tap, toArray } from 'rxjs/operators'; 4 | 5 | import { combineProps } from './combineProps'; 6 | 7 | describe('combineProps', () => { 8 | it('works correctly for no arguments', done => { 9 | combineProps() 10 | .pipe(toArray()) 11 | .subscribe(v => { 12 | expect(v).toMatchSnapshot(); 13 | done(); 14 | }); 15 | }); 16 | 17 | it('works correctly for empty arguments', done => { 18 | combineProps({}, {}, {}) 19 | .pipe(toArray()) 20 | .subscribe(v => { 21 | expect(v).toMatchSnapshot(); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('works correctly when props are passed', done => { 27 | combineProps({}, {}, { a: 1 }) 28 | .pipe(toArray()) 29 | .subscribe(v => { 30 | expect(v).toMatchSnapshot(); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('works correctly for observables', done => { 36 | combineProps( 37 | { 38 | a: of(1), 39 | b: of(2), 40 | }, 41 | {}, 42 | {} 43 | ) 44 | .pipe(toArray()) 45 | .subscribe(v => { 46 | expect(v).toMatchSnapshot(); 47 | done(); 48 | }); 49 | }); 50 | 51 | it('works correctly observers', done => { 52 | const a$ = new BehaviorSubject(0); 53 | combineProps( 54 | {}, 55 | { 56 | a: a$, 57 | }, 58 | {} 59 | ) 60 | .pipe(toArray()) 61 | .subscribe(v => { 62 | expect(v).toMatchSnapshot(); 63 | v[0].a(123); 64 | expect(a$.value).toBe(123); 65 | done(); 66 | }); 67 | }); 68 | 69 | it('works for complete sample', done => { 70 | const b$ = new BehaviorSubject(0); 71 | combineProps( 72 | { 73 | a: of(1), 74 | b: b$, 75 | }, 76 | { 77 | onB: b$, 78 | }, 79 | { 80 | c: 1, 81 | d: 2, 82 | } 83 | ) 84 | .pipe( 85 | tap(({ onB }) => setTimeout(onB, 1, 1)), 86 | take(2), 87 | toArray() 88 | ) 89 | .subscribe(v => { 90 | expect(v).toMatchSnapshot(); 91 | done(); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/combineProps.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer, of } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | 4 | import { combineLatestObj } from './combineLatestObj'; 5 | 6 | type AnyObservablesObject = { [k: string]: Observable }; 7 | type AnyObserversObject = { [k: string]: Observer }; 8 | 9 | /** 10 | * Creates observable combining values from observables, observers(as callbacks) and plain object 11 | * resulting in Observable of properties to be rendered with react component. 12 | */ 13 | export function combineProps< 14 | ObservablesObject extends AnyObservablesObject, 15 | ObserversObject extends AnyObserversObject, 16 | OtherProps extends {} 17 | >( 18 | observables?: ObservablesObject, 19 | observers?: ObserversObject, 20 | props?: OtherProps 21 | ): Observable< 22 | OtherProps & 23 | { 24 | [k in keyof ObservablesObject]: ObservablesObject[k] extends Observable< 25 | infer V 26 | > 27 | ? V 28 | : never; 29 | } & 30 | { 31 | [k in keyof ObserversObject]: ObserversObject[k] extends Observer 32 | ? (value: V) => void 33 | : never; 34 | } 35 | > { 36 | const baseProps: any = Object.assign({}, props); 37 | 38 | if (observers) { 39 | Object.keys(observers).forEach(key => { 40 | baseProps[key] = (value: any) => { 41 | observers[key].next(value); 42 | }; 43 | }); 44 | } 45 | 46 | if (observables && Object.keys(observables).length > 0) { 47 | return combineLatestObj(observables).pipe( 48 | map(newProps => Object.assign({}, baseProps, newProps)) 49 | ); 50 | } 51 | 52 | return of(baseProps); 53 | } 54 | -------------------------------------------------------------------------------- /src/connect.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { mount, render } from 'enzyme'; 4 | 5 | import { merge, Observable, of, Subject } from 'rxjs'; 6 | import { map, scan, startWith, switchMap } from 'rxjs/operators'; 7 | 8 | import { combineProps, connect, RxReactContainer } from '.'; 9 | 10 | interface AppProps { 11 | onMinus: (event: any) => void; 12 | onPlus: (event: any) => void; 13 | totalCount: number; 14 | title: string; 15 | } 16 | 17 | function App({ onMinus, onPlus, totalCount, title }: AppProps) { 18 | return ( 19 |
20 |

{title}

21 | 24 | [{totalCount}] 25 | 28 |
29 | ); 30 | } 31 | 32 | App.navStatic = { 33 | header: 'ok', 34 | }; 35 | 36 | interface ContainerProps { 37 | step: number; 38 | heading: string; 39 | } 40 | 41 | function sampleController( 42 | container: RxReactContainer 43 | ): Observable { 44 | const onMinus$ = new Subject(); 45 | const onPlus$ = new Subject(); 46 | 47 | const click$ = merge( 48 | onMinus$.pipe(map(() => -1)), 49 | onPlus$.pipe(map(() => +1)) 50 | ); 51 | const totalCount$ = container.getProp('step').pipe( 52 | switchMap(step => click$.pipe(map(v => v * step))), 53 | startWith(0), 54 | scan((acc, x) => acc + x, 0) 55 | ); 56 | 57 | const title$ = container 58 | .getProps('step', 'heading') 59 | .pipe(map(([step, heading]) => `${heading} - ${step}`)); 60 | 61 | return combineProps( 62 | { totalCount: totalCount$, title: title$ }, 63 | { onMinus: onMinus$, onPlus: onPlus$ } 64 | ); 65 | } 66 | 67 | const AppContainer = connect(sampleController)(App); 68 | 69 | test('connect', done => { 70 | const wrapper = mount(); 71 | expect(wrapper.find('#count').text()).toBe('0'); 72 | expect(wrapper.find('#title').text()).toBe('Test - 1'); 73 | 74 | wrapper.find('#plus').simulate('click'); 75 | 76 | expect(wrapper.find('#count').text()).toBe('1'); 77 | wrapper.find('#plus').simulate('click'); 78 | wrapper.find('#plus').simulate('click'); 79 | 80 | expect(wrapper.find('#count').text()).toBe('3'); 81 | wrapper.find('#minus').simulate('click'); 82 | wrapper.find('#minus').simulate('click'); 83 | 84 | expect(wrapper.find('#count').text()).toBe('1'); 85 | 86 | wrapper.setProps({ step: 3 }); 87 | expect(wrapper.find('#title').text()).toBe('Test - 3'); 88 | wrapper.find('#plus').simulate('click'); 89 | expect(wrapper.find('#count').text()).toBe('4'); 90 | wrapper.find('#minus').simulate('click'); 91 | expect(wrapper.find('#count').text()).toBe('1'); 92 | 93 | wrapper.setProps({ step: 3, heading: 'New' }); 94 | expect(wrapper.find('#title').text()).toBe('New - 3'); 95 | expect(wrapper.find('#count').text()).toBe('1'); 96 | 97 | wrapper.setProps({ step: 3 }); 98 | expect(wrapper.find('#title').text()).toBe('New - 3'); 99 | expect(wrapper.find('#count').text()).toBe('1'); 100 | 101 | wrapper.unmount(); 102 | done(); 103 | }); 104 | 105 | test('connect to throw if no observable returned', () => { 106 | expect(() => { 107 | // @ts-ignore 108 | const Cmp = connect(() => 0)(() => null); 109 | // @ts-ignore 110 | return new Cmp({}, {}); 111 | }).toThrow('controller should return an observable'); 112 | }); 113 | 114 | test('connect - displayName', () => { 115 | const Cmp1 = connect(() => of({}))(function Name1() { 116 | return null; 117 | }); 118 | expect(Cmp1.displayName).toBe('connect(Name1)'); 119 | 120 | const NODE_ENV = process.env.NODE_ENV; 121 | process.env.NODE_ENV = 'production'; 122 | 123 | const Cmp2 = connect(() => of({}))(function Name2() { 124 | return null; 125 | }); 126 | expect(Cmp2.displayName).toBe(undefined); 127 | 128 | process.env.NODE_ENV = NODE_ENV; 129 | }); 130 | 131 | test('connect - keep component statics', () => { 132 | // @ts-ignore 133 | expect(AppContainer.navStatic).toEqual({ header: 'ok' }); 134 | }); 135 | 136 | test('server side rendering', () => { 137 | const wrapper = render(); 138 | expect(wrapper.find('#count').text()).toBe('0'); 139 | expect(wrapper.find('#title').text()).toBe('Test - 1'); 140 | }); 141 | -------------------------------------------------------------------------------- /src/connect.ts: -------------------------------------------------------------------------------- 1 | import hoistStatics from 'hoist-non-react-statics'; 2 | import * as React from 'react'; 3 | import { ComponentType } from 'react'; 4 | import { BehaviorSubject, Observable, Subscription } from 'rxjs'; 5 | import { first, share } from 'rxjs/operators'; 6 | import { createGetProp, createGetProps, PropsHelper } from './PropsHelper'; 7 | 8 | /** 9 | * @deprecated alias to PropsHelper 10 | */ 11 | export type RxReactContainer = PropsHelper; 12 | 13 | export function connect( 14 | controller: (container: PropsHelper) => Observable 15 | ) { 16 | return (Component: ComponentType): ComponentType => { 17 | class Container extends React.Component< 18 | Props, 19 | { props: StateProps | null } 20 | > { 21 | public props$: BehaviorSubject; 22 | private subscription: Subscription | null; 23 | private stateProps$: Observable; 24 | private firstSubscription: Subscription; 25 | public getProp: PropsHelper['getProp']; 26 | public getProps: PropsHelper['getProps']; 27 | 28 | constructor(props: Props, context: object) { 29 | super(props, context); 30 | this.state = { props: null }; 31 | const props$ = new BehaviorSubject(props); 32 | this.props$ = props$; 33 | this.getProp = createGetProp(this.props$); 34 | this.getProps = createGetProps(this.props$); 35 | this.subscription = null; 36 | const propsHelper: PropsHelper = { 37 | get props(): Props { 38 | return props$.getValue(); 39 | }, 40 | props$: props$.asObservable(), 41 | getProp: createGetProp(props$), 42 | getProps: createGetProps(props$), 43 | }; 44 | const stateProps$ = controller(propsHelper); 45 | if (!stateProps$.subscribe) { 46 | throw new Error('controller should return an observable'); 47 | } 48 | this.stateProps$ = stateProps$.pipe(share()); 49 | // create subscription to get initial data 50 | // not creating permanent subscription, because componentWillUnmount is not called server-side 51 | // which in many cases will result in memory leak 52 | this.firstSubscription = this.stateProps$.pipe(first()).subscribe(p => { 53 | const newState = { props: p }; 54 | if (this.state.props !== null) { 55 | this.setState(newState); 56 | } else { 57 | this.state = newState; 58 | } 59 | }); 60 | } 61 | 62 | public componentDidMount() { 63 | this.subscription = this.stateProps$.subscribe(props => { 64 | this.setState({ props }); 65 | }); 66 | // in case no data was received before first render - remove duplicated subscription 67 | this.firstSubscription.unsubscribe(); 68 | } 69 | 70 | public componentDidUpdate() { 71 | this.props$.next(this.props); 72 | } 73 | 74 | public componentWillUnmount() { 75 | if (this.subscription) { 76 | this.subscription.unsubscribe(); 77 | } 78 | } 79 | 80 | public render() { 81 | return ( 82 | // @ts-expect-error 83 | this.state.props && React.createElement(Component, this.state.props) 84 | ); 85 | } 86 | static displayName: string; 87 | } 88 | 89 | if (process.env.NODE_ENV !== 'production') { 90 | const name = Component.displayName || Component.name; 91 | if (name) { 92 | Container.displayName = `connect(${name})`; 93 | } 94 | } 95 | 96 | return hoistStatics(Container, Component); 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { connect, RxReactContainer } from './connect'; 2 | export { combineProps } from './combineProps'; 3 | export { useRxController } from './useRxController'; 4 | export { PropsHelper } from './PropsHelper'; 5 | -------------------------------------------------------------------------------- /src/useRxContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { mount, render } from 'enzyme'; 4 | 5 | import { merge, Observable, Subject } from 'rxjs'; 6 | import { map, scan, startWith, switchMap } from 'rxjs/operators'; 7 | 8 | import { combineProps } from '.'; 9 | import { PropsHelper } from './PropsHelper'; 10 | import { useRxController } from './useRxController'; 11 | 12 | interface AppProps { 13 | onMinus: (event: any) => void; 14 | onPlus: (event: any) => void; 15 | totalCount: number; 16 | title: string; 17 | } 18 | 19 | interface ContainerProps { 20 | step: number; 21 | heading: string; 22 | } 23 | 24 | function sampleController( 25 | container: PropsHelper 26 | ): Observable { 27 | const onMinus$ = new Subject(); 28 | const onPlus$ = new Subject(); 29 | 30 | const click$ = merge( 31 | onMinus$.pipe(map(() => -1)), 32 | onPlus$.pipe(map(() => +1)) 33 | ); 34 | const totalCount$ = container.getProp('step').pipe( 35 | switchMap(step => click$.pipe(map(v => v * step))), 36 | startWith(0), 37 | scan((acc, x) => acc + x, 0) 38 | ); 39 | 40 | const title$ = container 41 | .getProps('step', 'heading') 42 | .pipe(map(([step, heading]) => `${heading} - ${step}`)); 43 | 44 | return combineProps( 45 | { totalCount: totalCount$, title: title$ }, 46 | { onMinus: onMinus$, onPlus: onPlus$ } 47 | ); 48 | } 49 | 50 | function App(props: ContainerProps) { 51 | const state = useRxController(sampleController, props); 52 | if (!state) return null; 53 | const { onMinus, onPlus, totalCount, title } = state; 54 | return ( 55 |
56 |

{title}

57 | 60 | [{totalCount}] 61 | 64 |
65 | ); 66 | } 67 | 68 | App.navStatic = { 69 | header: 'ok', 70 | }; 71 | 72 | const AppContainer = App; 73 | 74 | describe('useRxContainer', () => { 75 | test('base', done => { 76 | const wrapper = mount(); 77 | expect(wrapper.find('#count').text()).toBe('0'); 78 | expect(wrapper.find('#title').text()).toBe('Test - 1'); 79 | 80 | wrapper.find('#plus').simulate('click'); 81 | 82 | expect(wrapper.find('#count').text()).toBe('1'); 83 | wrapper.find('#plus').simulate('click'); 84 | wrapper.find('#plus').simulate('click'); 85 | 86 | expect(wrapper.find('#count').text()).toBe('3'); 87 | wrapper.find('#minus').simulate('click'); 88 | wrapper.find('#minus').simulate('click'); 89 | 90 | expect(wrapper.find('#count').text()).toBe('1'); 91 | 92 | wrapper.setProps({ step: 3 }); 93 | expect(wrapper.find('#title').text()).toBe('Test - 3'); 94 | wrapper.find('#plus').simulate('click'); 95 | expect(wrapper.find('#count').text()).toBe('4'); 96 | wrapper.find('#minus').simulate('click'); 97 | expect(wrapper.find('#count').text()).toBe('1'); 98 | 99 | wrapper.setProps({ step: 3, heading: 'New' }); 100 | expect(wrapper.find('#title').text()).toBe('New - 3'); 101 | expect(wrapper.find('#count').text()).toBe('1'); 102 | 103 | wrapper.setProps({ step: 3 }); 104 | expect(wrapper.find('#title').text()).toBe('New - 3'); 105 | expect(wrapper.find('#count').text()).toBe('1'); 106 | 107 | wrapper.unmount(); 108 | done(); 109 | }); 110 | 111 | test('server side rendering', () => { 112 | const wrapper = render(); 113 | expect(wrapper.find('#count').text()).toBe('0'); 114 | expect(wrapper.find('#title').text()).toBe('Test - 1'); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/useRxController.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { BehaviorSubject, Observable, Subscription } from 'rxjs'; 4 | import { share, first } from 'rxjs/operators'; 5 | import { createGetProp, createGetProps, PropsHelper } from './PropsHelper'; 6 | 7 | function createPropsHelpers( 8 | props$: BehaviorSubject 9 | ): PropsHelper { 10 | return { 11 | get props(): Props { 12 | return props$.getValue(); 13 | }, 14 | props$: props$.asObservable(), 15 | getProp: createGetProp(props$), 16 | getProps: createGetProps(props$), 17 | }; 18 | } 19 | 20 | export function useRxController( 21 | controller: (helper: PropsHelper) => Observable, 22 | props: Props 23 | ): StateProps | null { 24 | let initialState = null; 25 | const [internalState, setInternalState] = React.useState<{ 26 | state$?: Observable; 27 | subscription?: Subscription; 28 | props$?: BehaviorSubject; 29 | }>({}); 30 | 31 | // first render 32 | if (!internalState.props$) { 33 | const props$: BehaviorSubject = new BehaviorSubject(props); 34 | const state$: Observable = controller( 35 | createPropsHelpers(props$) 36 | ).pipe(share()); 37 | 38 | // if there are already some data - get it 39 | state$.pipe(first()).subscribe(v => { 40 | initialState = v; 41 | }); 42 | 43 | setInternalState({ ...internalState, state$, props$ }); 44 | } 45 | 46 | const [state, setState] = React.useState(initialState); 47 | useEffect(() => { 48 | const subscription: Subscription = internalState.state$!.subscribe( 49 | props => { 50 | setState(props); 51 | } 52 | ); 53 | setInternalState({ ...internalState, subscription }); 54 | return () => { 55 | subscription.unsubscribe(); 56 | }; 57 | }, [internalState.state$]); 58 | 59 | // push each props into behavior subject 60 | useEffect(() => { 61 | if (internalState.props$) { 62 | internalState.props$.next(props); 63 | } 64 | }, Object.values(props as any)); 65 | 66 | return state; 67 | } 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "esModuleInterop": true, 5 | "resolveJsonModule": true, 6 | "declaration": true, 7 | "declarationDir": "./dist", 8 | "moduleResolution": "node", 9 | "module": "es6", 10 | "strict": true, 11 | "outDir": "./dist", 12 | "target": "es5", 13 | "typeRoots": ["node_modules/@types"], 14 | "lib": [ 15 | "dom", 16 | "es5", 17 | "es2015.collection", 18 | "es2015.core", 19 | "es2015.promise", 20 | "es2015.iterable", 21 | "es2016.array.include", 22 | "es2017" 23 | ] 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------