actionware
v8.2.3
Published
Use Redux with much less boilerplate and get busy and error states for every action with no extra code.
Downloads
55
Maintainers
Readme
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