Bind actions instead of coupling them to the store
See original GitHub issueHiya!
Right now, actions must import an instance of the store in order to invoke setState()
. This creates a tight coupling of actions to the store, making testing require mocking that import. It’s a short-circuit that starts out quite nice (just import the store instance), but can get a bit hairy later on (can only have one store, can’t compose actions, etc).
Here’s a testing use-case that shows what I mean:
import { increment, decrement } from './actions'
import store from './store' // not part of the unit being tested
increment()
expect(store.getState()).to.eql({ count: 1 }) // asserting on another module
// ^ what if something else had changed the store? or messed with store.setState?
I’d like to propose a few lightweight options for making actions “late-bound”. Some of this comes from other experiments I’ve done with the Gist I sent.
Note: in the examples I’m using
Object
in place ofmapStateToProps
- I’m just using it as the identity function so that the entire store state is passed as props for the examples.
1. Simple store binding function
This is fairly analogous to redux’s approach, just replacing dispatch()
with the store instance itself. connect()
accepts a function as a 2nd argument that expects (store)
and returns actions that mutate that store. Perks: this remains compatible and nearly identical to the readme examples, the only difference being that store
is injected rather than imported (therein removing the singleton issue).
import { createStore, Provider, connect } from 'redux-zero'
export default () => <Provider store={createStore()}><App /></Provider>
const mapStateToProps = Object // or whatever
// same as actions.js from the readme, just instantiable with any `store`:
const createActions = store => ({
increment() {
// arguments passed to props.increment() are forwarded here
store.setState({
count: store.getState().count + 1
})
}
})
const App = connect(mapStateToProps, createActions)(
({ count, increment }) => (
<button onClick={increment}>{count}</button>
)
)
Implementation
This one is simple: add a second function argument to connect()
, then we just call it when constructing the class and pass it the store. That method returns us an object we can pass down as handler props.
export default function connect(mapToProps, createActions) {
return Child =>
class Connected extends React.Component {
// pass the store to createActions() and get back our bound actions:
actions = createActions(this.context.store)
// ...
render() {
return <Child store={this.context.store} {...this.actions} {...this.props} {...this.state} />
}
Testing Example
Since our straw-man was testing, here’s how you’d test with the above changes:
import createActions from './actions'
import createStore from 'redux-zero'
// we're importing lots still, but it's all (repeatedly) instantiable.
// Because we instantiated it, we know it's clean (no need to "reset").
let store = createStore({ count: 0 })
let actions = createActions(store)
actions.increment()
expect(store.getState()).to.eql({ count: 1 })
// ^ we know nothing outside could have affected this, it's our test store.
2. Auto-binding an actions map
This one is nice - instead of actions mutating the store directly, they get “bound” to the store by connect()
. Now all they have to do is accept (state, ...args)
as arguments and return a new state - everything else is automated.
It’s interesting to note - redux exposes a bindActionCreators()
method that does this, but it also includes this binding behavior by default when passing an object 2nd argument to connect()
.
import { createStore, Provider, connect } from 'redux-zero'
export default () => <Provider store={createStore()}><App /></Provider>
const mapStateToProps = Object // or whatever
// Actions are just pure functions that return a derivative state.
// Importantly, they don't need "hardwired" access to the store.
// Any arguments passed to the bound action are passed after state
const increment = ({ count }) => ({ count: count+1 })
const actions = { increment }
const App = connect(mapStateToProps, actions)(
({ count, increment }) => (
<button onClick={increment}>{count}</button>
)
)
Implementation
This adds one small step to connect()
- in Connected, we’d need to loop over the passed actions and create their store-bound proxies, then store the proxy function mapping as a property of the class. This could either happen in the constructor, as an instance property, or within componentWillMount.
function bindActions(actions, store) {
let bound = {}
for (let name in actions) {
bound[name] = (...args) => {
store.setState( actions[name](store.getState(), ...args) )
}
}
return bound
}
export default function connect(mapToProps, actions) {
return Child =>
class Connected extends React.Component {
// bind the pure actions to our store:
actions = bindActions(actions, this.context.store)
// ...
render() {
return <Child store={this.context.store} {...this.actions} {...this.props} {...this.state} />
}
Testing Example
The benefits of this approach get pretty clear when you look at how to test things. Since actions are now just pure functions that accept the current state and return a new one, they’re easy to test in complete isolation:
import { increment, decrement } from './actions' // not tied to a component
// no store instance needed
expect( increment({ count:0 }) ).to.eql({ count: 1 });
// ^ pure tests are almost pointlessly easy
One caveat with this approach is that async actions become a little harder. With option #1, actions can invoke store.setState()
in response to async stuff, it’ll just always be available. With option #2, there’d need to be some way to access the store later on. Another alternative would be to change bindActions()
to handle Promise return values from actions.
Hope this is useful, I’d love to see an option like this make its way into redux-zero!
Issue Analytics
- State:
- Created 6 years ago
- Reactions:16
- Comments:14 (5 by maintainers)
Top GitHub Comments
@malbernaz I’d avoid waiting on a Promise in the case where no thennable is returned (also, best to check for
.then
rather than assert on a promise constructor - it could be any promise impl):Yup - I would say #2 is ideal for synchronous updates, and #1 is ideal for asynchronous updates.
Here’s a hybrid of both approaches:
It works with both approaches mixed:
Personally I think this mixed approach ends up being ideal: it avoids the easy-but-broken implicit async via promises approach, allows for simple pure synchronous actions, and simplifies async. Best of all, every feature is entirely opt-in!