question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Bind actions instead of coupling them to the store

See original GitHub issue

Hiya!

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 of mapStateToProps - 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:closed
  • Created 6 years ago
  • Reactions:16
  • Comments:14 (5 by maintainers)

github_iconTop GitHub Comments

8reactions
developitcommented, Oct 14, 2017

@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):

function bindActions(actions, store) {
  let bound = {};

  for (let name in actions) {
    bound[name] = (...args) => {
      const result = actions[name](store.getState(), ...args);

      if (result && result.then) {
        result.then(store.setState);
      }
      else {
        store.setState(result);
      }
    };
  }

  return bound;
}
7reactions
developitcommented, Oct 19, 2017

Yup - I would say #2 is ideal for synchronous updates, and #1 is ideal for asynchronous updates.

Here’s a hybrid of both approaches:

function bindActions(actions, store) {
  let bound = {}
  for (let name in actions) {
    bound[name] = (...args) => {
      let ret = actions[name](store.getState(), ...args)
      if (ret!=null) store.setState(ret)
    }
  }
  return bound
}

export default function connect(mapToProps, actions) {
  return Child =>
    class Connected extends React.Component {
      constructor(_, { store }) {
        super()
        this.actions = bindActions(typeof actions==='function' ? actions(store) : actions, store)
      }
      // ...
      render() {
        return <Child store={this.context.store} {...this.actions} {...this.props} {...this.state} />
      }

It works with both approaches mixed:

// can be an object w/ pure functions,
// *or* an actions factory that accepts the store
const createActions = store => ({
  // example of a pure action
  increment: state => ({ count: state.count + 1 }),

  // state is always the first argument
  increment(state) {
    store.setState({ count: state.count + 1 })
    // no return value = no implicit state update
  },

  // example async action
  increment(state) {
    setTimeout( () => {
      // can use `state` here, or re-acquire state (better) via getState():
      store.setState({ pending: false, count: store.getState().count + 1 })
    })
    // can combine: here we return the intermediary state update:
    return { pending: true }
  }
})

const App = connect(Object, createActions)(
  ({ count, increment }) => (
    <button onClick={increment}>{count}</button>
  )
)

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!

Read more comments on GitHub >

github_iconTop Results From Across the Web

Action Creators - Human Redux
Redux includes a utility function called bindActionCreators for binding one or more action creators to the store's dispatch() function. Calling an action ......
Read more >
ATP cycle and reaction coupling | Energy (article)
ATP structure, ATP hydrolysis to ADP, and reaction coupling. ... It can be thought of as the main energy currency of cells, much...
Read more >
Coupling and Cohesion - Washington
Pass entire data structure but need only parts of it. Data coupling: use of parameter lists to pass data items between routines. COHESION....
Read more >
Coupling (computer programming) - Wikipedia
In software engineering, coupling is the degree of interdependence between software modules; ... Temporal coupling: It is when two actions are bundled together...
Read more >
The Difference Between Tight Coupling and Loose Coupling
Tight Coupling is the idea of binding resources to specific purposes and functions. Tightly-coupled components may bind each resource to a ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found