├── .gitignore ├── .nvmrc ├── .travis.yml ├── README.md ├── package.json ├── src └── refnux.coffee └── test ├── globals.coffee ├── mocha.opts ├── test-connect.coffee ├── test-provider.coffee └── test-store.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | node_modules 15 | npm-debug.log 16 | public 17 | .DS_Store 18 | .#* 19 | \#* 20 | bower_components 21 | doc 22 | 23 | BrowserStackLocal 24 | lib 25 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 6 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | notifications: 3 | email: false 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # refnux 2 | 3 | > re-fn-ux with emphasis on fn 4 | 5 | [![Build Status](https://travis-ci.org/algesten/refnux.svg?branch=master)](https://travis-ci.org/algesten/refnux) 6 | 7 | React's [Stateless Functions][stlss] means we can use simple functions 8 | instead of instances of component classes. 9 | 10 | By also letting go of partial updates to the DOM tree, we can make a 11 | super simple state store with actions that are like the flux pattern. 12 | 13 | Refnux is like redux, but using only functions instead of reducers. 14 | 15 | # initialize.js 16 | 17 | ```javascript 18 | import ReactDOM from 'react-dom'; 19 | import React from 'react'; 20 | import App from 'components/App'; 21 | 22 | import {createStore, Provider} from 'refnux'; 23 | 24 | var store = createStore({counter:42}); 25 | 26 | document.addEventListener('DOMContentLoaded', () => { 27 | ReactDOM.render(, document.querySelector('#app')); 28 | }); 29 | ``` 30 | 31 | ## Store 32 | 33 | The store is created over a simple state. This state will be 34 | propagated to all `connect`ed view functions and actions. 35 | 36 | ```javascript 37 | var store = createStore({counter:42}); 38 | ``` 39 | 40 | ### dispatch on store 41 | 42 | The store exposes a dispatch function (see dispatch below) 43 | 44 | ```javascript 45 | store.dispatch(myaction) 46 | ``` 47 | 48 | ### state on store 49 | 50 | The store also exposes a read only property `store.state` to get the current state 51 | 52 | ```javascript 53 | var current = store.state 54 | ``` 55 | 56 | ## Provider 57 | 58 | The Provider coordinates `connect`, `action` and rerendering of the 59 | DOM tree. The `app` provided must be a function. 60 | 61 | ``` 62 | 63 | ``` 64 | 65 | # App.js 66 | 67 | ```javascript 68 | import React from 'react' 69 | import {connect} from 'refnux' 70 | 71 | var action = (inc) => ({counter}) => { 72 | return {counter:counter + inc} 73 | } 74 | 75 | export default connect(({counter}, dispatch) => { 76 | return
77 | 78 | {counter} 79 | 80 |
81 | }); 82 | ``` 83 | 84 | ## Connect 85 | 86 | The `connect` function ensures the view function receives the state 87 | and `dispatch` on every rerender. 88 | 89 | ```javascript 90 | connect((state, dispatch) => { ... }) 91 | ``` 92 | 93 | ## Actions 94 | 95 | ### dispatch runs an action 96 | 97 | ```javascript 98 | var myaction = (state) => { ... } 99 | ... 100 | dispatch(myaction) 101 | ``` 102 | 103 | ### an action is just a function 104 | 105 | An action is just a function taking the state and returning the keys 106 | that have been changed. 107 | 108 | ```javascript 109 | var action = (state) => { 110 | return {counter:state.counter + 1} 111 | } 112 | ``` 113 | 114 | Use scope to pass parameters to actions. 115 | 116 | ```javascript 117 | var action = (inc) => ({counter}) => { 118 | return {counter:counter + inc} 119 | } 120 | ``` 121 | 122 | ### async 123 | 124 | All actions receive a second argument that is a `dispatch` function to 125 | be used asynchronously. 126 | 127 | ```javascript 128 | var myaction = (state, dispatch) => { ... } 129 | ... 130 | dispatch(myaction) 131 | ``` 132 | 133 | #### async example 134 | 135 | ```javascript 136 | var handleResponse = (user) => (state) => { 137 | return {user:user, info:"Got user"} 138 | } 139 | 140 | var requestUser = (userId) => (state, dispatch) => { 141 | io.emit('getUser', userId, (user) => { 142 | dispatch(handleResponse(user)) 143 | }) 144 | return {info:"Requesting user"} 145 | } 146 | ``` 147 | 148 | N.B. it is an error to use the dispatch function synchronously. A 149 | dispatch can't be called in the same call stack as another dispatch. 150 | 151 | 152 | #### ISC License (ISC) 153 | 154 | Copyright (c) 2016, Martin Algesten 155 | 156 | Permission to use, copy, modify, and/or distribute this software for 157 | any purpose with or without fee is hereby granted, provided that the 158 | above copyright notice and this permission notice appear in all 159 | copies. 160 | 161 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 162 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 163 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 164 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL 165 | DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR 166 | PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 167 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 168 | PERFORMANCE OF THIS SOFTWARE. 169 | 170 | 171 | [stlss]: https://facebook.github.io/react/docs/reusable-components.html#stateless-functions 172 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refnux", 3 | "version": "1.5.1", 4 | "description": "re-fn-ux with emphasis on fn", 5 | "main": "lib/refnux.js", 6 | "author": "Martin Algesten", 7 | "license": "ISC", 8 | "scripts": { 9 | "prepublish": "coffee -m -c -o lib src", 10 | "watch": "coffee -w -m -c -o lib src", 11 | "test": "mocha" 12 | }, 13 | "files": [ 14 | "lib" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:algesten/refnux.git" 19 | }, 20 | "keywords": [ 21 | "flux", 22 | "store", 23 | "unidirectional", 24 | "react", 25 | "redux" 26 | ], 27 | "devDependencies": { 28 | "chai": "^3.5.0", 29 | "coffee-script": "^1.10.0", 30 | "mocha": "^3.0.2", 31 | "sinon": "^1.17.5", 32 | "sinon-chai": "^2.8.0", 33 | "react": "^0.14 || ^15", 34 | "react-dom": "^0.14 || ^15" 35 | }, 36 | "peerDependencies": { 37 | "react": "^0.14 || ^15" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/algesten/refnux/issues" 41 | }, 42 | "dependencies": { 43 | "try-prop-types": "^1.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/refnux.coffee: -------------------------------------------------------------------------------- 1 | {Component, createElement} = require 'react' 2 | PropTypes = require 'try-prop-types' 3 | 4 | # test if object is an object (not array) 5 | isobject = (o) -> !!o and typeof o == 'object' and !Array.isArray(o) 6 | 7 | # create a store over a state 8 | createStore = (state = {}) -> 9 | 10 | # store listeners for change 11 | listeners = [] 12 | 13 | # subscribe for store changes 14 | subscribe = (listener) -> 15 | 16 | # check it's actually a listener 17 | throw new Error("Listener must be a function") unless typeof(listener) == 'function' 18 | 19 | # remember it 20 | listeners.push listener 21 | 22 | # return unsubscribe function 23 | -> 24 | index = listeners.indexOf(listener) 25 | return if index < 0 26 | listeners.splice(index, 1) 27 | 28 | # set a new state and tell all listeners about it 29 | setState = (newstate) -> 30 | prevstate = state 31 | state = newstate 32 | listeners.forEach (l) -> l state, prevstate 33 | 34 | # one at a time 35 | dispatching = false 36 | 37 | # dispatch the action (function). 38 | dispatch = (action) -> 39 | 40 | # must be a function 41 | throw new Error("Action must be a function") unless typeof action == 'function' 42 | 43 | # only one at a time 44 | throw new Error("dispatch in dispatch is not allowed") if dispatching 45 | dispatching = true 46 | 47 | # execute the action 48 | try 49 | newval = action state, dispatch 50 | 51 | if newval and typeof(newval.then) is 'function' 52 | dispatching = false 53 | return newval.then( 54 | (val) -> dispatch(-> val) 55 | ) 56 | 57 | catch err 58 | throw err 59 | finally 60 | dispatching = false 61 | 62 | # sanity check 2 63 | throw new Error("Action must return an object") unless isobject(newval) 64 | change = false # check if we have a change 65 | for k, v of newval 66 | unless state.hasOwnProperty(k) 67 | throw new Error("Action returned key (#{k}) missing in state") 68 | change |= state[k] != v 69 | 70 | # no change? 71 | return state unless change 72 | 73 | # create a new state 74 | newstate = Object.assign {}, state, newval 75 | 76 | # update the state 77 | setState newstate 78 | 79 | # return new state 80 | newstate 81 | 82 | # exposed facade 83 | store = {subscribe, dispatch} 84 | 85 | # state property read only getter 86 | Object.defineProperty store, 'state', 87 | enumerable: true 88 | get: -> state 89 | set: -> throw new Error("store.state is read only") 90 | 91 | # the finished store 92 | store 93 | 94 | 95 | class Provider extends Component 96 | 97 | constructor: (props) -> 98 | super 99 | if Array.isArray(props.children) and props.children.length > 1 100 | throw new Error("Provider does not support multiple children") 101 | {@store, @app} = props 102 | 103 | if props.children? 104 | throw new Error 'Provider: can\'t set app component both as property and child' if @app 105 | @app = props.children 106 | if false and typeof(props.children) isnt 'function' 107 | @app = -> props.children 108 | 109 | @state = props.store.state 110 | 111 | getChildContext: => { store: this.props.store } 112 | 113 | componentDidMount: => 114 | # start listening to changes in the store 115 | @unsubscribe = @store.subscribe (newstate) => @setState(newstate) 116 | 117 | componentWillUnmount: => 118 | # stop listening to the store 119 | @unsubscribe?() 120 | @unsubscribe = null 121 | 122 | render: => 123 | if typeof(@app) is 'function' 124 | return @app() 125 | return @app 126 | 127 | 128 | storeShape = PropTypes.shape( 129 | subscribe: PropTypes.func.isRequired, 130 | dispatch: PropTypes.func.isRequired, 131 | state: PropTypes.object.isRequired 132 | ) 133 | 134 | # app and state are required 135 | Provider.propTypes = { 136 | app: PropTypes.func 137 | store: storeShape.isRequired 138 | } 139 | 140 | Provider.childContextTypes = { 141 | store: storeShape.isRequired 142 | } 143 | 144 | # injection point for getting newly created elements 145 | proxy = doproxy: (v) -> v 146 | 147 | # connected stateless functions receive a dispatch function to execute actions 148 | connect = (viewfn) -> 149 | 150 | # ensure arg is good 151 | throw new Error("connect requires a function argument") unless typeof(viewfn) == 'function' 152 | 153 | # create a unique instance of Connected for each connected component 154 | # this is used to wire up the store via context. 155 | Connected = (props, context) -> 156 | 157 | throw new Error("No provider in scope.") unless context.store 158 | 159 | # the current state/dispatch 160 | {state, dispatch} = context.store 161 | 162 | # invoke the actual view function 163 | viewfn(state, dispatch, props) 164 | 165 | # this is the magic 166 | Connected.contextTypes = { 167 | store: storeShape.isRequired 168 | } 169 | 170 | # receive incoming props, and create instance of the new Connected 171 | (props) -> proxy.doproxy createElement Connected, (props ? {}) 172 | 173 | module.exports = {createStore, Provider, connect, proxy} 174 | -------------------------------------------------------------------------------- /test/globals.coffee: -------------------------------------------------------------------------------- 1 | chai = require 'chai' 2 | global.assert = chai.assert 3 | global.eql = assert.deepEqual 4 | chai.use require 'sinon-chai' 5 | sinon = require 'sinon' 6 | global.stub = sinon.stub 7 | global.spy = sinon.spy 8 | global.sandbox = sinon.sandbox 9 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers coffee:coffee-script/register 2 | --reporter list 3 | --timeout 500 4 | -------------------------------------------------------------------------------- /test/test-connect.coffee: -------------------------------------------------------------------------------- 1 | 2 | {createStore, Provider, connect} = require '../src/refnux' 3 | 4 | {createFactory, Component} = require 'react' 5 | {renderToString} = require 'react-dom/server' 6 | {div} = require('react').DOM 7 | pf = createFactory Provider 8 | 9 | describe 'connect', -> 10 | 11 | store = createStore(panda:42) 12 | 13 | it 'requires a function arg', -> 14 | assert.throws (->connect null), 'connect requires a function argument' 15 | 16 | it 'connects functions on store render', -> 17 | app = connect vf = spy -> div(null, 'abc') 18 | pel = pf({app, store}) 19 | eql vf.args, [] 20 | html = renderToString pel 21 | eql html, '
abc
' 23 | eql vf.args, [[{panda:42}, store.dispatch, {}]] 24 | 25 | it 'passes properties to wrapped component', -> 26 | props = { some: 'prop' } 27 | component = connect vf = spy -> div(null, 'abc') 28 | app = -> component(props) 29 | pel = pf({app, store}) 30 | eql vf.args, [] 31 | html = renderToString pel 32 | eql vf.args, [[{panda:42}, store.dispatch, props]] 33 | 34 | it 'complains if connected function is used outside provider', -> 35 | app = connect (state, dispatch, props) -> div(null, 'abc') 36 | fail = -> renderToString div null, app() 37 | assert.throws fail, 'No provider in scope.' 38 | 39 | it 'connects nested sub-components', -> 40 | nspy = null 41 | nested = connect nspy = spy (state) -> div(null, "l2 #{state.panda}") 42 | app = connect (state) -> div(null, "l1 #{state.panda}", nested()) 43 | pel = pf({app, store}) 44 | html = renderToString pel 45 | eql html, '
l1 42
l2 42
' 46 | eql nspy.args, [[store.state, store.dispatch, {}]] 47 | -------------------------------------------------------------------------------- /test/test-provider.coffee: -------------------------------------------------------------------------------- 1 | 2 | {createStore, Provider, connect} = require '../src/refnux' 3 | 4 | {createFactory, createElement} = require 'react' 5 | {renderToString} = require 'react-dom/server' 6 | {div} = require('react').DOM 7 | pf = createFactory Provider 8 | 9 | 10 | describe 'Provider', -> 11 | 12 | {createFactory} = require 'react' 13 | {renderToString} = require 'react-dom/server' 14 | {div} = require('react').DOM 15 | pf = createFactory Provider 16 | app = spy -> div(null, 'abc') 17 | store = createStore(panda:42) 18 | 19 | it 'requires a store and an app function', -> 20 | pel = pf({app, store}) 21 | pel2 = pf({ children: app, store }) 22 | assert pel.props.app == pel2.props.children 23 | 24 | it 'does not allow to define app as both property and child', -> 25 | pel = pf({app, store, children: app}) 26 | assert.throws( 27 | -> renderToString pel 28 | , 29 | 'Provider: can\'t set app component both as property and child' 30 | ) 31 | 32 | it 'invokes the app function on render', -> 33 | pel = pf({app, store}) 34 | html = renderToString pel 35 | eql html, '
abc
' 37 | eql app.args, [[]] 38 | 39 | it 'rerenders on store change', -> 40 | pel = pf({children: app, store}) 41 | renderToString pel 42 | store.dispatch -> panda:43 43 | eql app.args, [[],[]] 44 | 45 | -------------------------------------------------------------------------------- /test/test-store.coffee: -------------------------------------------------------------------------------- 1 | 2 | {createStore, Provider, connect} = require '../src/refnux' 3 | 4 | {createFactory} = require 'react' 5 | {renderToString} = require 'react-dom/server' 6 | {div} = require('react').DOM 7 | pf = createFactory Provider 8 | 9 | describe 'createStore', -> 10 | 11 | ['subscribe', 'dispatch'].forEach (fn) -> 12 | it "makes a store with #{fn}", -> 13 | o = createStore() 14 | eql typeof(o[fn]), 'function' 15 | eql typeof(o.state), 'object' 16 | 17 | it 'makes an initial empty state', -> 18 | o = createStore() 19 | eql o.state, {} 20 | 21 | it 'takes an initial state as arg', -> 22 | o = createStore(panda:42) 23 | eql o.state, panda:42 24 | 25 | it 'cant write to store.state', -> 26 | o = createStore(panda:42) 27 | assert.throws (->o.state = {}), 'store.state is read only' 28 | 29 | describe 'dispatch', -> 30 | 31 | o = st = null 32 | beforeEach -> 33 | st = panda:42 34 | o = createStore st 35 | 36 | it 'doesnt modify the incoming state', -> 37 | assert st == o.state, 'initial state is unmodified' 38 | 39 | it 'takes an action function as argument', -> 40 | o.dispatch -> {} 41 | eql o.state, panda:42 42 | 43 | it 'doesnt change the state when no mutation', -> 44 | o.dispatch -> {} 45 | assert o.state == st 46 | 47 | it 'doesnt change the state when no change', -> 48 | o.dispatch -> panda:42 49 | assert o.state == st 50 | 51 | it 'refuses any other type of arg', -> 52 | assert.throws (->o.dispatch null), 'Action must be a function' 53 | assert.throws (->o.dispatch { }), 'Action must be a function' 54 | 55 | it 'requires action to return an object', -> 56 | act = -> null 57 | assert.throws (->o.dispatch act), 'Action must return an object' 58 | 59 | it 'mixes in action return into state', -> 60 | act = -> panda:43 61 | o.dispatch act 62 | eql o.state, panda:43 63 | eql st, panda:42 64 | 65 | it 'doesnt modify state objects', -> 66 | act = -> panda:43 67 | o.dispatch act 68 | assert st != o.state, 'not same state' 69 | 70 | it 'refuses keys not already present', -> 71 | act = -> cub:true 72 | assert.throws (->o.dispatch act), 'Action returned key (cub) missing in state' 73 | 74 | it 'invokes action with (state, dispatch)', -> 75 | act = spy -> {} 76 | o.dispatch act 77 | eql act.args, [[st, o.dispatch]] 78 | 79 | it 'refuses dispatch in dispatch', -> 80 | act = spy (state, dispatch) -> 81 | assert.throws (->dispatch (->)), 'dispatch in dispatch is not allowed' 82 | o.dispatch act 83 | eql act.args.length, 1 84 | 85 | it 'accepts async actions', -> 86 | act = -> new Promise( 87 | (res, rej) -> 88 | setTimeout( 89 | -> res({panda: 43}) 90 | , 1) 91 | ) 92 | assert o.state.panda == 42 93 | o.dispatch(act).then -> 94 | assert o.state.panda == 43 95 | 96 | it 'correctly handles rejected async actions', -> 97 | error = new Error 'wtf' 98 | act = -> new Promise( 99 | (res, rej) -> 100 | setTimeout( 101 | -> rej(error) 102 | , 1) 103 | ) 104 | o.dispatch(act) 105 | .catch (e) -> e 106 | .then (e) -> assert e == error 107 | 108 | it 'correctly handles unexpected errors in async actions', -> 109 | error = new Error 'wtf' 110 | act = -> new Promise( 111 | (res, rej) -> throw error 112 | ) 113 | o.dispatch(act) 114 | .catch (e) -> e 115 | .then (e) -> assert e == error 116 | 117 | it 'returns new state after dispatching an action', -> 118 | newstate = o.dispatch -> {} 119 | assert o.state == st 120 | assert o.state == newstate 121 | 122 | it 'returns new state after dispatching an async action', -> 123 | act = -> new Promise( 124 | (res, rej) -> 125 | setTimeout( 126 | -> res({panda: 43}) 127 | , 1) 128 | ) 129 | assert o.state.panda == 42 130 | o.dispatch(act).then (newstate) -> 131 | assert o.state.panda == 43 132 | assert o.state == newstate 133 | 134 | 135 | describe 'subscribe', -> 136 | 137 | o = st = null 138 | beforeEach -> 139 | st = panda:42 140 | o = createStore st 141 | 142 | it 'takes a listener function', -> 143 | o.subscribe -> 144 | 145 | it 'is angry if not a function', -> 146 | assert.throws (->o.subscribe null), 'Listener must be a function' 147 | 148 | it 'returns an unsubscribe function', -> 149 | un = o.subscribe -> 150 | eql typeof(un), 'function' 151 | 152 | it 'invokes subscriber on state change', -> 153 | o.subscribe s = spy -> 154 | o.dispatch -> panda:43 155 | eql s.args, [[{panda:43},{panda:42}]] 156 | assert s.args[0][1] == st 157 | 158 | it 'doesnt invokes subscriber after unsubscribe', -> 159 | un = o.subscribe s = spy -> 160 | un() 161 | o.dispatch -> panda:43 162 | eql s.args, [] 163 | 164 | it 'is ok to do multiple unsubscribes', -> 165 | un = o.subscribe s = spy -> 166 | un() 167 | un() 168 | un() 169 | un() 170 | --------------------------------------------------------------------------------