ReactionPack offers a simple way to decouple actions and state changes from view components. With ReactionPack properly built components are stateless, use only props, and are easily unit testable.
This state management package draws heavily from Redux but attempts to simplify development for small and large applications. The major difference with this library is that there are no reducers or constants to worry about. Actions return with state changes eliminating the need for constants and reducers. Additionally asynchronous actions and computed values are first class citizens with no need for external packages.
- Zen mode component development (Stateless, simple, organized, and testable. See: Component Development)
- No Redux (No constants and reducers, just actions)
- No Thunk (Actions are natively asynchronous. See: Actions)
- No Reselect (Built in support for computed values. See: Computed Values)
- Small package size
npm install reactionpack --save
Every ReactionPack must have one state container component. The state container uses the components internal state to store the tree of state for nested connected components. To define the state container use the following function:
createStateContainer(Component, [initialState])
- Component (React Component) - the component to be used as the root state container
- initialState (object) - (optional) Initial state tree object
- Component (React Component) - a new wrapped component that hosts the app's root state container.
import { createStateContainer } from 'reactionpack';
const AppContainer = createStateContainer(App, { initialState });
render(
<AppContainer />,
document.getElementById('root')
);
connectToProps(Component, [actions], [computedValues], [namespace])
- Component (React Component)
- actions (object) - (optional) key names map to properties
- computedValues (object) - (optional) key names map to properties
- namespace (string) - (optional) the base name in state where the components state is managed
- Component (React Component) - a new component with actions and computed values bound to props.
import { connectToProps } from 'reactionpack';
const ConnectedPage = connectToProps(Page, actions, computedValues);
Props are composed in the following priority order from highest to lowest.
- prop passed in from owner component
- computed value from bound computed function
- action bound function
- state value from state container (if defined in propTypes)
- component default props
Actions are either synchronous returning an object or null, or asynchronous returning a promise. Actions that call other actions should call them via getActions
and not directly. Example: this.getActions().someAction()
.
// Returns a state changes
actionName(state, [value, ...]) {
return {
...state
someValue: value
}
}
// Returns a promise
actionName(state, [value, ...]) {
return fetchApiStuff(value).then((res) => {
return {
...this.getState();
things: res.things;
}
})
}
// Returns the result of another action
actionName(state, [value, ...]) {
...
return this.getActions().anotherAction(value);
}
Actions should return one of three types of values: An object representing the new state, a promise, or null.
- (object): Container state will be replaced with returned object.
- Promise: Container state will be updated with the resolved object.
null
: No state change required
Actions are bound with functions in context for accessing other actions, computed values, default values, and the current state.
- this.getActions()
- this.getComputed()
- this.getDefaults()
- this.getState()
ReactionPack has computed value support built in. Inspired by Reselect, computed values can be defined using an array containing one or more value selector functions and a computation function.
export const computedDef = [
selectorFunc,
<more selector functions>,
computeFunc
]
(Excerpt taken from the todomvc example app)
// Selector definitions
function getTodos(state) {
return state.todos;
}
function getFilter(state) {
return state.filter;
}
// Exported computed value definition
export const filteredTodos = [
getTodos,
getFilter,
(todos, filter) => _.filter(todos, TODO_FILTERS[filter]),
];
The recommend folder structure for components looks like the following:
Component/
├── actions.js (Action functions get mapped to props by name)
├── actions.spec.js
├── computed-values.js (Computed value definitions get mapped to props by name)
├── computed-values.spec.js
├── index.js (Returns the connected component)
├── View.jsx (Stateless view component)
└── View.spec.js
When testing synchronous actions (those that do not return a promise) simply call the action function directly.
const todo = {
id: 1,
completed: false,
text: 'Some todo',
editText: null,
editing: false,
};
const state = {
todosById: {
1: todo,
},
};
const result = onBeginEdit(state, todo);
expect(result).toEqual({
todosById: {
1: {
id: 1,
completed: false,
text: 'Some todo',
editText: 'Some todo',
editing: true,
},
},
});
When testing asynchronous actions use the mockActions
helper. The helper binds all actions as a unit and then test individual actions as methods on the returned object. Mock out other actions on this object as needed.
mockActions(actions)
import { mockActions } from 'reactionpack';
...
const todo = {
id: 1,
completed: false,
text: 'Some todo',
editing: false,
};
const mockedActions = mockActions(actions);
mockedActions.saveTodo = jest.fn(() => Promise.resolve({})); // Mock out action called by `onSaveTodo`
return mockedActions.onSaveTodo(todo).then((result) => {
return expect(mockedActions.saveTodo).toBeCalledWith(todo);
});
When testing computed value definitions use the createComputed
helper to build a the compute function.
createComputed(computedValueDefintion)
const state = {
todos: [{
id: 1,
completed: false,
}, {
id: 2,
completed: true,
}],
};
const computeCompletedCount = createComputed(completedCount);
expect(computeCompletedCount(state)).toEqual(1);
Make sure all action and computed value names are defined in the component's propTypes. If not they will be ignored by the connected component. Added the following eslint rule to help prevent this problem going forward: react/prop-types: 1
// App.jsx
...
export default connectToProps(App, actions, computed);
// ./pages/home.jsx && /pages/about.jsx
export default connectToProps(Home, actions, computed);
// Routes.jsx
import { createStateContainer } from 'reactionpack';
import App from './app';
import Home from './pages/home';
import About from './pages/about';
const Routes = (
<Route path='/' component={createStateContainer(App)}>
<Route path='/home' component={Home} />
<Route path='/about' component={About} />
</Route>
);
ReactionPack can be used with the Redux DevTools Chrome extension. To do this import and pass the installDevTools
function to state container:
import { installDevTools } from 'reactionpack';
render(
<AppContainer installDevTools={installDevTools} />,
document.getElementById('root')
);