├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── package-lock.json ├── package-scripts.js ├── package.json ├── rollup.config.js └── src ├── ReactCallbagListener.js ├── ReactCallbagListener.test.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "loose": true, 7 | "targets": { 8 | "node": "8" 9 | } 10 | } 11 | ], 12 | "react", 13 | "stage-2" 14 | ], 15 | "env": { 16 | "test": { 17 | "plugins": ["transform-react-jsx-source", "istanbul"] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "rules": { 4 | "jsx-a11y/href-no-hash": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.iml 3 | .nyc_output 4 | coverage 5 | node_modules 6 | dist 7 | lib 8 | es 9 | npm-debug.log 10 | .DS_Store 11 | yarn.lock 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | trailingComma: none 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '8' 10 | script: 11 | - npm start validate 12 | after_success: 13 | - npx codecov 14 | branches: 15 | only: 16 | - master -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👂 React Callbag Listener 2 | 3 | [![NPM Downloads](https://img.shields.io/npm/dm/react-callbag-listener.svg?style=flat)](https://www.npmjs.com/package/react-callbag-listener) 4 | [![Build Status](https://travis-ci.org/erikras/react-callbag-listener.svg?branch=master)](https://travis-ci.org/erikras/react-callbag-listener) 5 | [![codecov.io](https://codecov.io/gh/erikras/react-callbag-listener/branch/master/graph/badge.svg)](https://codecov.io/gh/erikras/react-callbag-listener) 6 | 7 | --- 8 | 9 | So you've seen the light and accepted [Callbags](https://github.com/callbag/callbag) as the future of reactive front-end development, but you need to update a React component every time a callbag emits a new value? 10 | 11 | 👂 React Callbag Listener is the answer! 12 | 13 | --- 14 | 15 | ## Demo 👀 16 | 17 | [![Edit 👂 React Callbag Listener Demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/4y2z5j6v7) 18 | 19 | --- 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install --save react-callbag-listener 25 | ``` 26 | 27 | or 28 | 29 | ```bash 30 | yarn add react-callbag-listener 31 | ``` 32 | 33 | ## How it works 34 | 35 | You provide any number of callbags as props to 👂 React Callbag Listener, and the render function given as `children` will be rendered whenever any of them changes. 36 | 37 | ```jsx 38 | import CallbagListener from 'react-callbag-listener' 39 | 40 | ... 41 | 42 | // foo$ and bar$ are callbag sources that will emit values 43 | 44 | {({ foo, bar }) => ( 45 |
46 |
Foo value is: {foo}
47 |
Bar value is: {bar}
48 |
49 | )} 50 |
51 | ``` 52 | 53 | That's it. There are no other options or API to document. The object given to your render prop will have the same keys as you passed as callbag props. 54 | -------------------------------------------------------------------------------- /package-scripts.js: -------------------------------------------------------------------------------- 1 | const npsUtils = require("nps-utils"); 2 | 3 | const series = npsUtils.series; 4 | const concurrent = npsUtils.concurrent; 5 | const rimraf = npsUtils.rimraf; 6 | const crossEnv = npsUtils.crossEnv; 7 | 8 | module.exports = { 9 | scripts: { 10 | test: { 11 | default: crossEnv("NODE_ENV=test jest --coverage"), 12 | update: crossEnv("NODE_ENV=test jest --coverage --updateSnapshot"), 13 | watch: crossEnv("NODE_ENV=test jest --watch"), 14 | codeCov: crossEnv( 15 | "cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js" 16 | ), 17 | size: { 18 | description: "check the size of the bundle", 19 | script: "bundlesize" 20 | } 21 | }, 22 | build: { 23 | description: "delete the dist directory and run all builds", 24 | default: series( 25 | rimraf("dist"), 26 | concurrent.nps( 27 | "build.es", 28 | "build.cjs", 29 | "build.umd.main", 30 | "build.umd.min" 31 | ) 32 | ), 33 | es: { 34 | description: "run the build with rollup (uses rollup.config.js)", 35 | script: "rollup --config --environment FORMAT:es" 36 | }, 37 | cjs: { 38 | description: "run rollup build with CommonJS format", 39 | script: "rollup --config --environment FORMAT:cjs" 40 | }, 41 | umd: { 42 | min: { 43 | description: "run the rollup build with sourcemaps", 44 | script: "rollup --config --sourcemap --environment MINIFY,FORMAT:umd" 45 | }, 46 | main: { 47 | description: "builds the cjs and umd files", 48 | script: "rollup --config --sourcemap --environment FORMAT:umd" 49 | } 50 | }, 51 | andTest: series.nps("build", "test.size") 52 | }, 53 | docs: { 54 | description: "Generates table of contents in README", 55 | script: "doctoc README.md" 56 | }, 57 | lint: { 58 | description: "lint the entire project", 59 | script: "eslint ." 60 | }, 61 | validate: { 62 | description: 63 | "This runs several scripts to make sure things look good before committing or on clean install", 64 | default: concurrent.nps("lint", "build.andTest", "test") 65 | } 66 | }, 67 | options: { 68 | silent: false 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-callbag-listener", 3 | "version": "1.0.2", 4 | "description": 5 | "👂 A React render-prop component that listens to values emitted by callbags", 6 | "main": "dist/react-callbag-listener.cjs.js", 7 | "jsnext:main": "dist/react-callbag-listener.es.js", 8 | "module": "dist/react-callbag-listener.es.js", 9 | "files": ["dist"], 10 | "scripts": { 11 | "start": "nps", 12 | "test": "nps test", 13 | "precommit": "lint-staged && npm start validate", 14 | "prepublish": "lint-staged && npm start validate" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/erikras/react-callbag-listener.git" 19 | }, 20 | "keywords": ["react", "callbag", "observable", "reactive"], 21 | "author": "Erik Rasmussen ", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/erikras/react-callbag-listener/issues" 25 | }, 26 | "homepage": "https://github.com/erikras/react-callbag-listener#readme", 27 | "devDependencies": { 28 | "babel-eslint": "^8.2.3", 29 | "babel-jest": "^22.4.3", 30 | "babel-plugin-external-helpers": "^6.22.0", 31 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 32 | "babel-preset-env": "^1.6.1", 33 | "babel-preset-react": "^6.24.1", 34 | "babel-preset-stage-2": "^6.24.1", 35 | "bundlesize": "^0.17.0", 36 | "eslint": "^4.19.1", 37 | "eslint-config-react-app": "^2.1.0", 38 | "eslint-plugin-babel": "^5.1.0", 39 | "eslint-plugin-flowtype": "^2.46.3", 40 | "eslint-plugin-import": "^2.11.0", 41 | "eslint-plugin-jsx-a11y": "^6.0.3", 42 | "eslint-plugin-react": "^7.7.0", 43 | "husky": "^0.14.3", 44 | "jest": "^22.4.3", 45 | "lint-staged": "^7.0.4", 46 | "nps": "^5.9.0", 47 | "nps-utils": "^1.5.0", 48 | "prettier": "^1.12.1", 49 | "prop-types": "^15.6.1", 50 | "react": "^16.3.2", 51 | "react-dom": "^16.3.2", 52 | "rollup": "^0.58.2", 53 | "rollup-plugin-babel": "^3.0.4", 54 | "rollup-plugin-commonjs": "^9.1.0", 55 | "rollup-plugin-node-resolve": "^3.3.0", 56 | "rollup-plugin-replace": "^2.0.0", 57 | "rollup-plugin-uglify": "^3.0.0" 58 | }, 59 | "peerDependencies": { 60 | "prop-types": "^15.6.1", 61 | "react": "^16.3.2" 62 | }, 63 | "lint-staged": { 64 | "*.{js*,json,md,css}": ["prettier --write", "git add"] 65 | }, 66 | "bundlesize": [ 67 | { 68 | "path": "dist/react-callbag-listener.umd.min.js", 69 | "threshold": "2kB" 70 | }, 71 | { 72 | "path": "dist/react-callbag-listener.es.js", 73 | "threshold": "3kB" 74 | }, 75 | { 76 | "path": "dist/react-callbag-listener.cjs.js", 77 | "threshold": "3kB" 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import babel from 'rollup-plugin-babel' 3 | import commonjs from 'rollup-plugin-commonjs' 4 | import uglify from 'rollup-plugin-uglify' 5 | import replace from 'rollup-plugin-replace' 6 | import pkg from './package.json' 7 | 8 | const minify = process.env.MINIFY 9 | const format = process.env.FORMAT 10 | const es = format === 'es' 11 | const umd = format === 'umd' 12 | const cjs = format === 'cjs' 13 | 14 | let output 15 | 16 | if (es) { 17 | output = { file: `dist/react-callbag-listener.es.js`, format: 'es' } 18 | } else if (umd) { 19 | if (minify) { 20 | output = { 21 | file: `dist/react-callbag-listener.umd.min.js`, 22 | format: 'umd' 23 | } 24 | } else { 25 | output = { file: `dist/react-callbag-listener.umd.js`, format: 'umd' } 26 | } 27 | } else if (cjs) { 28 | output = { file: `dist/react-callbag-listener.cjs.js`, format: 'cjs' } 29 | } else if (format) { 30 | throw new Error(`invalid format specified: "${format}".`) 31 | } else { 32 | throw new Error('no format specified. --environment FORMAT:xxx') 33 | } 34 | 35 | export default { 36 | input: 'src/index.js', 37 | output: Object.assign( 38 | { 39 | name: 'react-callbag-listener', 40 | exports: 'named', 41 | globals: { 42 | react: 'React', 43 | 'prop-types': 'PropTypes' 44 | } 45 | }, 46 | output 47 | ), 48 | external: umd 49 | ? Object.keys(pkg.peerDependencies || {}) 50 | : [ 51 | ...Object.keys(pkg.dependencies || {}), 52 | ...Object.keys(pkg.peerDependencies || {}) 53 | ], 54 | plugins: [ 55 | resolve({ jsnext: true, main: true }), 56 | commonjs({ include: 'node_modules/**' }), 57 | babel({ 58 | exclude: 'node_modules/**', 59 | babelrc: false, 60 | presets: [['env', { loose: true, modules: false }], 'stage-2'], 61 | plugins: [ 62 | 'external-helpers', 63 | ['transform-react-remove-prop-types', { mode: 'unsafe-wrap' }] 64 | ] 65 | }), 66 | umd 67 | ? replace({ 68 | 'process.env.NODE_ENV': JSON.stringify( 69 | minify ? 'production' : 'development' 70 | ) 71 | }) 72 | : null, 73 | minify ? uglify() : null 74 | ].filter(Boolean) 75 | } 76 | -------------------------------------------------------------------------------- /src/ReactCallbagListener.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class ReactCallbagProps extends React.Component { 5 | static propTypes = { 6 | children: PropTypes.func 7 | } 8 | 9 | talkbacks = {} 10 | state = {} 11 | 12 | componentDidMount() { 13 | Object.keys(this.props) 14 | .filter(key => key !== 'children') 15 | .forEach(key => { 16 | const callbag = this.props[key] 17 | if (callbag) { 18 | this.subscribe(key, callbag) 19 | } 20 | }) 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | // look for new props 25 | Object.keys(this.props).forEach(key => { 26 | const newCallbag = this.props[key] 27 | const oldCallbag = prevProps[key] 28 | if (newCallbag !== oldCallbag) { 29 | if (oldCallbag === undefined) { 30 | // got a new one! 31 | this.subscribe(key, newCallbag) 32 | } else { 33 | // callbag changed, unsubscribe and resubscribe to new one 34 | const talkback = this.talkbacks[key] 35 | if (talkback) { 36 | talkback(2) 37 | } 38 | this.unsubscribe(key, oldCallbag) 39 | this.subscribe(key, newCallbag) 40 | } 41 | } 42 | }) 43 | Object.keys(prevProps).forEach(key => { 44 | const newCallbag = this.props[key] 45 | const oldCallbag = prevProps[key] 46 | if (oldCallbag && !newCallbag) { 47 | this.talkbacks[key](2) 48 | this.unsubscribe(key, oldCallbag) 49 | } 50 | }) 51 | } 52 | 53 | componentWillUnmount() { 54 | Object.keys(this.talkbacks).forEach(key => this.talkbacks[key](2)) 55 | } 56 | 57 | subscribe = (key, callbag) => { 58 | callbag(0, (type, data) => { 59 | if (type === 0) this.talkbacks[key] = data 60 | else if (type === 1) this.setState({ [key]: data }) 61 | else if (type === 2) this.unsubscribe(key, callbag) 62 | 63 | if (type === 0 || type === 1) this.talkbacks[key](1) 64 | }) 65 | } 66 | 67 | unsubscribe = (key, callbag) => { 68 | this.setState({ [key]: undefined }) 69 | delete this.talkbacks[key] 70 | } 71 | 72 | render() { 73 | return this.props.children(this.state) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ReactCallbagListener.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TestUtils from 'react-dom/test-utils' 3 | import ReactCallbagListener from './ReactCallbagListener' 4 | 5 | describe('ReactCallbagListener', () => { 6 | const makeTestCallbag = () => { 7 | let sink 8 | const unsubscribe = jest.fn() 9 | return { 10 | send: data => sink(1, data), 11 | cancel: () => sink(2), 12 | nonsense: () => sink(42), 13 | callbag: (type, data) => { 14 | if (type !== 0) return 15 | sink = data 16 | sink(0, t => { 17 | if (t === 2) { 18 | unsubscribe() 19 | } 20 | }) 21 | }, 22 | unsubscribe 23 | } 24 | } 25 | 26 | it('should subscribe to a callbag on mount', () => { 27 | const callbag = jest.fn() 28 | TestUtils.renderIntoDocument( 29 | {() =>
} 30 | ) 31 | expect(callbag).toHaveBeenCalled() 32 | expect(callbag).toHaveBeenCalledTimes(1) 33 | expect(callbag.mock.calls[0][0]).toBe(0) 34 | expect(typeof callbag.mock.calls[0][1]).toBe('function') 35 | }) 36 | 37 | it('should unsubscribe from a callbag on unmount', () => { 38 | const { callbag, unsubscribe } = makeTestCallbag() 39 | 40 | class Container extends React.Component { 41 | state = { 42 | shown: true 43 | } 44 | render() { 45 | return ( 46 |
47 | {this.state.shown && ( 48 | 49 | {() =>
} 50 | 51 | )} 52 | 55 |
56 | ) 57 | } 58 | } 59 | const dom = TestUtils.renderIntoDocument() 60 | const button = TestUtils.findRenderedDOMComponentWithTag(dom, 'button') 61 | 62 | expect(unsubscribe).not.toHaveBeenCalled() 63 | TestUtils.Simulate.click(button) 64 | expect(unsubscribe).toHaveBeenCalled() 65 | expect(unsubscribe).toHaveBeenCalledTimes(1) 66 | }) 67 | 68 | it('should listen to a callbag', () => { 69 | const unsubscribe = jest.fn() 70 | const { callbag, send } = makeTestCallbag() 71 | const render = jest.fn(() =>
) 72 | 73 | expect(render).not.toHaveBeenCalled() 74 | expect(unsubscribe).not.toHaveBeenCalled() 75 | TestUtils.renderIntoDocument( 76 | {render} 77 | ) 78 | expect(render).toHaveBeenCalled() 79 | expect(render).toHaveBeenCalledTimes(1) 80 | expect(render.mock.calls[0][0]).toEqual({}) 81 | 82 | send('bar') 83 | 84 | expect(render).toHaveBeenCalledTimes(2) 85 | expect(render.mock.calls[1][0]).toEqual({ foo: 'bar' }) 86 | 87 | expect(unsubscribe).not.toHaveBeenCalled() 88 | }) 89 | 90 | it('should unsubscribe when callbag prop disappears', () => { 91 | const { callbag, unsubscribe } = makeTestCallbag() 92 | 93 | class Container extends React.Component { 94 | state = { 95 | useCallbag: true 96 | } 97 | render() { 98 | const props = {} 99 | if (this.state.useCallbag) { 100 | props.foo = callbag 101 | } 102 | return ( 103 |
104 | 105 | {() =>
} 106 | 107 | 110 |
111 | ) 112 | } 113 | } 114 | const dom = TestUtils.renderIntoDocument() 115 | const button = TestUtils.findRenderedDOMComponentWithTag(dom, 'button') 116 | 117 | expect(unsubscribe).not.toHaveBeenCalled() 118 | TestUtils.Simulate.click(button) 119 | expect(unsubscribe).toHaveBeenCalled() 120 | expect(unsubscribe).toHaveBeenCalledTimes(1) 121 | }) 122 | 123 | it('should subscribe when new callbag prop appears', () => { 124 | const callbag = jest.fn() 125 | class Container extends React.Component { 126 | state = { 127 | useCallbag: false 128 | } 129 | render() { 130 | return ( 131 |
132 | 135 | {() =>
} 136 | 137 | 140 |
141 | ) 142 | } 143 | } 144 | const dom = TestUtils.renderIntoDocument() 145 | const button = TestUtils.findRenderedDOMComponentWithTag(dom, 'button') 146 | 147 | expect(callbag).not.toHaveBeenCalled() 148 | TestUtils.Simulate.click(button) 149 | expect(callbag).toHaveBeenCalled() 150 | expect(callbag.mock.calls[0][0]).toBe(0) 151 | expect(typeof callbag.mock.calls[0][1]).toBe('function') 152 | }) 153 | 154 | it('should unsubscribe/subscribe when callbag prop changes', () => { 155 | const { callbag, unsubscribe } = makeTestCallbag() 156 | const callbagB = jest.fn() 157 | class Container extends React.Component { 158 | state = { 159 | useB: false 160 | } 161 | render() { 162 | return ( 163 |
164 | 165 | {() =>
} 166 | 167 | 168 |
169 | ) 170 | } 171 | } 172 | const dom = TestUtils.renderIntoDocument() 173 | const button = TestUtils.findRenderedDOMComponentWithTag(dom, 'button') 174 | 175 | expect(unsubscribe).not.toHaveBeenCalled() 176 | expect(callbagB).not.toHaveBeenCalled() 177 | TestUtils.Simulate.click(button) 178 | expect(unsubscribe).toHaveBeenCalled() 179 | expect(unsubscribe).toHaveBeenCalledTimes(1) 180 | expect(callbagB).toHaveBeenCalled() 181 | expect(callbagB.mock.calls[0][0]).toBe(0) 182 | expect(typeof callbagB.mock.calls[0][1]).toBe('function') 183 | }) 184 | 185 | it('should clear value if source is cancelled', () => { 186 | const { callbag, send, cancel } = makeTestCallbag() 187 | const render = jest.fn(() =>
) 188 | 189 | TestUtils.renderIntoDocument( 190 | {render} 191 | ) 192 | 193 | expect(render).toHaveBeenCalled() 194 | expect(render).toHaveBeenCalledTimes(1) 195 | expect(render.mock.calls[0][0]).toEqual({}) 196 | 197 | send(42) 198 | 199 | expect(render).toHaveBeenCalledTimes(2) 200 | expect(render.mock.calls[1][0]).toEqual({ foo: 42 }) 201 | 202 | cancel() 203 | 204 | expect(render).toHaveBeenCalledTimes(3) 205 | expect(render.mock.calls[2][0]).toEqual({}) 206 | }) 207 | 208 | it('should ignore invalid nonsense messages', () => { 209 | // this is mostly for code coverage reasons 210 | const { callbag, send, nonsense } = makeTestCallbag() 211 | const render = jest.fn(() =>
) 212 | 213 | TestUtils.renderIntoDocument( 214 | {render} 215 | ) 216 | 217 | expect(render).toHaveBeenCalled() 218 | expect(render).toHaveBeenCalledTimes(1) 219 | expect(render.mock.calls[0][0]).toEqual({}) 220 | 221 | send(42) 222 | 223 | expect(render).toHaveBeenCalledTimes(2) 224 | expect(render.mock.calls[1][0]).toEqual({ foo: 42 }) 225 | 226 | nonsense() 227 | 228 | expect(render).toHaveBeenCalledTimes(2) 229 | }) 230 | 231 | it('should listen to multiple callbags', () => { 232 | const { callbag: callbagA, send: sendA } = makeTestCallbag() 233 | const { callbag: callbagB, send: sendB } = makeTestCallbag() 234 | const render = jest.fn(() =>
) 235 | 236 | expect(render).not.toHaveBeenCalled() 237 | TestUtils.renderIntoDocument( 238 | 239 | {render} 240 | 241 | ) 242 | expect(render).toHaveBeenCalled() 243 | expect(render).toHaveBeenCalledTimes(1) 244 | expect(render.mock.calls[0][0]).toEqual({}) 245 | 246 | sendA(42) 247 | 248 | expect(render).toHaveBeenCalledTimes(2) 249 | expect(render.mock.calls[1][0]).toEqual({ a: 42 }) 250 | 251 | sendB(33) 252 | 253 | expect(render).toHaveBeenCalledTimes(3) 254 | expect(render.mock.calls[2][0]).toEqual({ a: 42, b: 33 }) 255 | 256 | sendA(4) 257 | 258 | expect(render).toHaveBeenCalledTimes(4) 259 | expect(render.mock.calls[3][0]).toEqual({ a: 4, b: 33 }) 260 | }) 261 | }) 262 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./ReactCallbagListener"; 2 | --------------------------------------------------------------------------------