Skip to content

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

License

Notifications You must be signed in to change notification settings

wellguimaraes/actionware

Repository files navigation

Actionware

Build Status

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

  • no more action creators and action types, just actions¹ and reducers
  • actions dispatch their result automatically
  • error status for every action with no extra code
  • busy status for every async action (yep, no extra code!)
  • cancellable actions

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

Extra power

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

Setup

Install it

  • Yarn: yarn add actionware
  • NPM: npm i actionware --save

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

  • setup({ store, defaultPrefix?, errorSuffix?, busySuffix?, cancelSuffix? }): void

Most used

  • withActions(actions: object): Function(wrappedComponent: Component)
  • createActions(actions: object): object
  • isBusy(action: Function): bool
  • getError(action: Function): object
  • clearError(action: Function): void
  • call(action: Function, ...args)
  • next(action: Function)
  • createReducer(initialState: object, handlers: []): Function

Listeners

Global
  • onSuccess(listener: ({ action, payload, args, store }) => void)
  • onError(listener: ({ action, error, args, store }) => void)
  • beforeAll(listener: ({ action, args, store}) => void)
Per action
  • onSuccess(action: Function, listener: ({ payload, args, store }) => void)
  • onError(action: Function, listener: ({ error, args, store }) => void)
  • before(action: Function, listener: ({ args, store }) => void)

Test helpers

  • mockCallWith(fakeCall: Function)
  • mockNextWith(fakeNext: Function)
  • successType(action: Function)
  • errorType(action: Function)
  • busyType(action: Function)

License

MIT © Wellington Guimaraes

About

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

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published