├── .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 | [](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, ''
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 |
--------------------------------------------------------------------------------