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.
These observations have landed us on a pattern where you group related action creators and reducers 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
:
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
export function add(id, text, completed = false) {
return {type: ADD_TODO, id, text, completed};
}
export function toggle(id) {
return {type: TOGGLE_TODO};
}
export function reducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return _reduceAdd(state, action);
case TOGGLE_TODO:
return _reduceToggle(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,
};
});
}
The store would wire it up by referencing todos.reducer
, and components would trigger actions via a dispatch(todos.add())
, etc. Hopefully a pretty straightforward pattern.
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.
For example, if we wanted to add a server to the mix, we might modify the above interaction to include another action creator:
todos.js
:
…
export function addLocal(id, text, completed = false) {
return {type: ADD_TODO, id, text, completed};
}
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});
dispatch(markSaved(todo.id));
} catch (error) {
dispatch(flash.error(`Failed to save todo, please try again`));
dispatch(removeLocal(todo.id));
}
};
}
…
This new action creator nicely encapsulates all business logic around adding a todo to the application. For ease of discussion, let's call it a business action (creator). And the more focused state modification actions like addLocal
as state action (creators).
Business actions very rarely dispatch a raw action directly. Instead, they compose state actions and frequently perform asynchronous workflows.
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 } from 'redux-interactions';
export default new class Todos extends Interactions {
initialState = [];
@reducer
addLocal(state, id, text, completed = false) {
return [
...state,
{id, text, completed},
];
}
@reducer('CUSTOM_TODOS_TOGGLE')
toggleLocal(state, id) {
return state.map(todo => {
if (todo.id !== id) return todo;
return {
...todo,
completed: !todo.completed,
};
});
}
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) {
dispatch(flash.error(`Failed to save todo, please try again`));
dispatch(this.removeLocal(todo.id));
}
};
}
};