redux-interactions
A streamlined approach to managing your Redux action creators and reducers.
For a TL;DR of what this package provides, you can skip down to Streamlining The Pattern.
If you wish to see it in practice, check out examples/todomvc-react
. You can run it locally by checking out this repository and running npm run example
.
A Pattern
From using Flux/Redux for a while, we've observed a few things:
- Developers naturally think of action creators and reducers as a group of code (i.e. each action creator tends to be associated with a single piece of reducer logic).
- When we make use of broad actions that are consumed by multiple reducers, it quickly becomes difficult to track/test/maintain all of the effects of that action.
- A lot of business logic is necessarily asynchronous, which can't be encapsulated in a reducer.
- Most reducers are singletons, and
These observations have landed us on a pattern where you group related action creators, reducers, and selectors into a single file, which we call an interaction.
For example, a hypothetical, offline-only, todos app might have a "todos" interaction that manages the state of the todos being displayed to the user:
todos.js
:
import * as _ from 'lodash';
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const REMOVE_TODO = 'REMOVE_TODO';
export const MOUNT_POINT = ['entities', 'todos'];
export function getAll(state) {
return _.values(_.get(state, MOUNT_POINT));
}
export function getById(state, id) {
return _.get(state, [...MOUNT_POINT, id]);
}
export function add(text, completed = false) {
return async (dispatch, _getState) => {
const todo = {id: uuid.v4(), text, completed, saving: true};
dispatch(addLocal(todo));
try {
apiRequest('post', '/todos', {body: todo});
} catch (error) {
alert(`Failed to save todo, please try again`);
dispatch(removeLocal(todo.id));
}
};
}
export function addLocal(id, text, completed = false) {
return {type: ADD_TODO, id, text, completed};
}
export function toggleLocal(id) {
return {type: TOGGLE_TODO};
}
export function removeLocal(id, id) {
return {type: REMOVE_TODO, id};
}
export function reducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return _reduceAdd(state, action);
case TOGGLE_TODO:
return _reduceToggle(state, action);
case REMOVE_TODO:
return _reduceRemove(state, action);
default:
return state;
}
}
function _reduceAdd(state, action) {
return [
...state,
{id, text, completed},
];
}
function _reduceToggle(state, action) {
return state.map(todo => {
if (todo.id !== id) return todo;
return {
...todo,
completed: !todo.completed,
};
});
}
function _reduceRemove(state, action) {
return _.omit(state, action.id);
}
The store would wire it up by mounting todos.reducer
at entities.todos
, and components would trigger actions via a dispatch(todos.add())
, etc. Components use todos.getById
and todos.getAll
to retrieve state from that slice of the store.
The interesting outcome of this approach is that it subtly changes your approach to managing business logic: Each action/reducer pair tends to encapsulate a single state modification, rather than an a swath of business logic. More complicated business logic naturally flows via thunked actions that complete all of our state modification actions/reducers.
Streamlining The Pattern
Now that we have that pattern in place, we can look at codifying it a bit more. Notice all the boilerplate surrounding state actions? Yeah, we can do better.
import { Interactions, reducer, selector } from 'redux-interactions';
export default new class Todos extends Interactions {
mountPoint = ['entities', 'todos'];
initialState = [];
@selector
getAll(scopedState) {
return _.values(scopedState);
}
@selector
getById(scopedState, id) {
return scopedState[id];
}
add(text, completed = false) {
return async (dispatch, _getState) => {
const todo = {id: uuid.v4(), text, completed, saving: true};
dispatch(this.addLocal(todo));
try {
apiRequest('post', '/todos', {body: todo});
dispatch(this.markSaved(todo.id));
} catch (error) {
alert(`Failed to save todo, please try again`);
dispatch(this.removeLocal(todo.id));
}
};
}
@reducer
addLocal(scopedState, id, text, completed = false) {
return [
...scopedState,
{id, text, completed},
];
}
@reducer('CUSTOM_TODOS_TOGGLE')
toggleLocal(scopedState, id) {
return scopedState.map(todo => {
if (todo.id !== id) return todo;
return {
...todo,
completed: !todo.completed,
};
});
}
@reducer
removeLocal(scopedState, id) {
return _.omit(scopedState, id);
}
};
Mounting Interactions
Interactions need to be aware of where they are mounted in the store, since they are providing selectors. In order to centralize the store's configuration, you will probably want to use combineInteractions()
to mount them, and to set their mount points:
import { combineInteractions } from 'redux-interactions';
import * as interactions from './interactions';
const interactionsReducer = combineInteractions({
entities: {
todos: interaction.todos,
},
});