Home

Awesome

Actionware

Build Status

Redux with less boilerplate, actions statuses and controlled side-effects in a single shot.

<small>¹ With Actionware, actions have a different meaning: they're just functions which execution generate events. See usage section to better understand.</small>

Extra power

Wanna have state selectors/getters in a decent way? Use it combined with Stateware lib.

Setup

Install it

After creating your Redux store, let Actionware know your store instance. Optionally you

can define custom action types prefix and suffixes:

import * as actionware from 'actionware';

actionware.setup({
  store,
  defaultPrefix, // default: 'actionware:'
  errorSuffix,   // default: ':error'
  cancelSuffix,  // default: ':cancel'
  busySuffix     // default: ':busy'
});

Add actionware reducer to your root reducer:

To make Redux store react to busy and error status changes, make sure you add the Actionware reducer into your root reducer.

import { combineReducers } from 'redux';
import { actionwareReducer } from 'actionware';

const rootReducer = combineReducers({ 
  actionware: actionwareReducer,
  // your reducers
});

Usage

Simple actions

export function incrementCounter() { }

Async actions

Whatever you return will be the action payload

// Note that the store is always the last arg
export async function loadUsers(arg1, arg2, argN, store) {
  const response = await fetch('/my/api/users');
  return response.json();
}

Invoke any action

Use call to invoke an action and let Actionware handle the execution lifecycle (managing error and busy statuses, notifying listeners, etc).

import { call } from 'actionware';

call(loadUsers, arg1, arg2, argN);

Cancel an action execution

import { call } from 'actionware';

const actionCall = call(loadUsers, arg1, arg2, argN);

actionCall.cancel()

To cancel inner calls or other async executions, use setExtra inside an async action to keep information needed and use them on a cancellation listener:

import { call, onCancel} from 'actionware';
import api from './path/to/api';

// Don't use arrow functions here, 
// otherwise a context value can't be set
export async function someAction() {
  const apiCall = api.get('/some/endpoint')
  const anotherActionCall = call(anotherAction, 'someParam')
  
  this.setExtra({ apiCall })
  this.setExtra({ anotherActionCall }) // you can call it multiple times
    
  const apiResponse = await apiCall
  const anotherResponse = await anotherActionCall
  
  // ...
  
  return apiResponse.data
}

export async function anotherAction() {
  // ...
}

onCancel(someAction, ({ extras }) => {
  // Check if the action execution is still cancellable
  if (extras.anotherActionCall.canBeCancelled)
    extras.anotherActionCall.cancel()
    
  // Cancel the api call...
})

Clear action error

import { clearError } from 'actionware'

export async function someAction() {
  // ...
}

clearError(someAction)

Reducers:

import { createReducer } from 'actionware';
import { loadUsers, persistUser, incrementCounter } from 'path/to/actions';

const initialState = { users: [], count: 0 };

export default createReducer(initialState)
  .on(loadUsers, 
    (state, users) => ({ ...state, users }))
  
  .on(incrementCounter, 
    (state) => ({ ...state, counter: state.counter + 1 }))
  
  // Bind legacy action types
  .on('OLD_ACTION_TYPE',
    (state, payload) => { /* return new state */ })
  
  // Bind multiple actions to the same handler    
  .on(
    someAction, 
    anotherAction,
    (state, payload) => { /* return new state */ })
  
  // Actionware handles errors, cancellation and 'before' events,
  // but if you need to do something else
  
  .onError(persistUser, 
    (state, error, ...args) => { /* return new state */ })
    
  .onCancel(loadUsers, 
    (state, extras, ...args) => { /* return new state */ })
  
  .before(loadUsers, 
    (state, ...args) => { /* return new state */ });

Busy and failure statuses for all your actions:

import { getError, isBusy } from 'actionware';
import { loadUsers } from 'path/to/userActions';

// Whenever needed...
isBusy(loadUsers);
getError(loadUsers);

Use listeners to manage side effects:

Note that busy listeners are called when busy status changes.

import { onSuccess, onError, onCancel, before, beforeAll } from 'actionware';
import { createUser } from 'path/to/actions';

// global success listener
onSuccess(({ action, args, payload, store }) => eventTracker.register(action.name));

// per action success listener
onSuccess(createUser, ({ args, payload, store }) => history.push(`/users/${user.id}`));

// error listeners
onError(({ action, args, error }) => { /* ... */ });
onError(createUser, ({ args, error }) => { /* ... */ });

// cancellation listeners
onCancel(({ action, args, extras }) => { /* ... */ });
onCancel(createUser, ({ args, extras }) => { /* ... */ });

// before listeners 
// NOTE: 'beforeAll' is just an alias for 'before'
beforeAll(({ action, args, store}) => { /* ... */ });
before(createUser, ({ args, store }) => { /* ... */ });

Interaction-dependent flows

When you have "complex" flows that depend on some interaction to start or continue, you can use next to wait for some action completion in this fashion:

import { call, next } from 'actionware';
import { login, showTip, acknowledgeTip } from 'path/to/actions';

export async function appEducationFlow() {
  // Wait for the next successful login
  await next(login); 
  
  call(showTip, 'headerButtons');
  await next(acknowledgeTip);
  
  history.redirect('/some/route');
  
  call(showTip, 'sideMenu');
  await next(acknowledgeTip);
}

// At some point, start the flow
appEducationFlow();

Usage with React

Inject actions and status into components as props

By using withActions to wrap a component, actions are injected into it as props and can be invoked without using call.

import * as React from 'react';
import { connect } from 'react-redux';
import { withActions, isBusy, getError } from 'actionware';
import { loadUsers } from 'path/to/actions';

const actions = { loadUsers };

const mapStateToProps = ({ company }) => ({
  users   : company.users,
  loading : isBusy(loadUsers),
  error   : getError(loadUsers)
});

@connect(mapStateToProps)
@withActions(actions)
class MyConnectedComponent extends Component {
  componentDidMount() {
    this.props.loadUsers();    
  }
  
  render() {
    const { loading, error } = this.props;
    
    if (loading) return (<div>Loading...</div>);
    if (error) return (<div>Failed to load users...</div>);
    
    return (
      <div>
        { users.map(it => <User key={it.id} {...it} />) }
      </div>
    );
  }
}

export default MyConnectedComponent

Without injecting actions as props

In case you prefer not injecting actions as props into your component, you can use createActions this way:

import { createActions } from 'actionware'

const actions = createActions('optionalPrefix:', {
  someAction,
  anotherAction
})

const MyComponent = () => (
  <div>
    <button onClick={ actions.someAction }></button>
  </div>
)

Testing

Mock call and next functions

While testing, you're able to replace the call and next functions by custom spy/stub to simplify tests.

import { mockCallWith, mockNextWith } from 'actionware';

const callSpy = sinon.spy();
const nextStub = sinon.stub().returns(Promise.resolve());

mockCallWith(callSpy);
mockNextWith(nextStub);

// Get back to default behavior
mockCallWith(null); 
mockNextWith(null); 

Reducers

For testing reducers, you can do the following:

import { successType } from 'actionware';
import { loadUsers } from 'path/to/userActions';
import usersReducer from 'path/to/usersReducer';

describe('usersReducer', () => {
  describe('on loadUsers', () => {
    it('should replace the "users" array with the loaded users', () => {
      const currentState = { users: [ ] }; 
      const loadedUsers = [ 'John Doe', 'Joane Doe', 'Steve Gates' ];

      // Call reducer with currentState and a regular Redux action       
      const newState = usersReducer(
        currentState, 
        { type: successType(loadUsers), payload: loadedUsers }
      );
      
      expect(newState.items).to.equals(loadedUsers);
    });  
  });
});

API

Setup

Most used

Listeners

Global
Per action

Test helpers

License

MIT © Wellington Guimaraes