redux-rsi
v5.0.0
Published
Utility & helper functions for reducing the boilerplate necessary when creating redux reducers & actions
Downloads
57
Readme
Redux RSI
Reduce your risk of repetitive strain injury when using Redux
Utility & helper functions for reducing the boilerplate necessary when creating redux reducers, actions, & stores. It also includes an implementation of a reducer registry to aid in the creation of redux modules when composing large applications.
Install
yarn add redux-rsi
or
npm install redux-rsi --save
API Reference
createReducer(initialState, handlers)
As your app grows more complex, a switch
statement in your reducer no longer cuts it when trying to handle actions. The createReducer
function takes the initial state of your reducer and will autowire actions to the object you pass in as the handlers
parameter. e.g. If an action of type MessageSend
is dispatched, it will be autowired to a function with the same name with the on
prefix applied, e.g. onMessageSend
. Internally, it uses lodash's string methods to wire the methods, so dispatching with type messageSend
, message-send
, or MESSAGE_SEND
will still work.
createReducer
assumes your actions are all Flux Standard Actions. If an action handler cannot be found, the current state will be returned to the caller unmodified.
import { Immutable } from "seamless-immutable"; //using seamless-immutable for immutable state in our reducer
import { createReducer } from "redux-rsi";
const users = createReducer(Immutable({
isAuthenticating: false,
isAuthenticated: false,
user: null
}), {
//this will respond to USER_AUTHENTICATE
onUserAuthenticate(state, credentials) {
return state.merge({
isAuthenticating: true,
isAuthenticated: false,
user: null
});
},
//this will respond to USER_AUTHENTICATE_COMPLETED
onUserAuthenticateCompleted(state, user) {
return state.merge({
isAuthenticating: false,
isAuthenticated: true,
user: user
});
},
handleError(state, type, err) {
//do something nice with this error
console.log(err);
return state;
}
});
createAjaxAction(action, getPromise)
If you are using the redux-thunk middleware to make AJAX calls, you might find yourself constantly writing action creators similar to the following:
export function fetchUser(username) {
return (dispatch, getState) => {
if (!!getState().users.get(username)) {
//don't fetch if the user is already loaded in the current state
return;
}
api.fetchUser(username) //this is a separate API lib which will make the AJAX call and return a promise
.then(response => dispatch(fetchCompleted(response))
.catch(err => dispatch(fetchFailed(err));
dispatch({
type: "USERS_FETCH",
payload: username
});
};
}
function fetchCompleted(response) {
return {
type: "USERS_FETCH_COMPLETED",
payload: response.body
};
}
function fetchFailed(err) {
return {
type: "USERS_FETCH_FAILED",
payload: err
};
}
createAjaxAction
will help you reduce this boilerplate when making an AJAX call:
import { createAjaxAction } from "redux-rsi";
export function fetchUser(username) {
return createAjaxAction({
type: "USERS_FETCH",
payload: username
}, getState => {
if (!!getState().users.get(username)) {
//don't fetch if the user is already loaded in the current state
return;
}
return api.fetchUser(username);
});
}
The first argument is the action you want to dispatch. The action type will be used to create two new action types (one with a _COMPLETED
appended and one with a _FAILED
appended).
The second argument is your function that will return a Promise
. If nothing is returned, nothing will be dispatched (e.g. in this case, if the user has already been fetched).
If you have no reason to check the current state, this example can be reduced further:
export function fetchUser(username) {
return createAjaxAction({
type: "USERS_FETCH",
payload: username
}, () => api.fetchUser(username));
}
asyncifyAction(action)
This will "asyncify" an action by returning to you the completed & failed action types by appending _COMPLETED
and _FAILED
to your original action type.
import { asyncifyAction } from "redux-rsi";
const fetchActions = asyncifyAction({
type: "USERS_FETCH",
payload: username
});
export function fetchUser(username) {
// assuming using redux-thunk here
return dispatch => {
// dispatch the original action USERS_FETCH
dispatch(fetchActions.request);
// simulate an ajax request & dispatch USERS_FETCH_COMPLETED after 2 seconds
setTimeout(fetchActions.completed({ hello: "from the server" }), 2000);
// ...OR...
// simulate a failed ajax request & dispatch USERS_FETCH_FAILED after 2 seconds
setTimeout(fetchActions.failed({ message: "error from the server" }), 2000);
};
}
The failed & completed actions have the payload from the original action set as their meta
(see Flux Standard Actions).
This is what createAjaxAction
uses internally.
mergeWithCurrent(state, key, data, [initFn])
If your state tree is a keyed object, you may find yourself writing a lot of code that looks something like this:
export default createReducer(Immutable({}), {
onSomethingHappened(state, data) {
// first, get the current item in our state because we don't want to overwrite anything
// if no item exists, just create an empty item
let item = state[data.key] || initEmptyItem(data.key);
// merge the new data from the server (while not overwriting anything the server didn't send back)
item = item.merge(data);
// set the new data on the state and return it
return state.set(data.key, item);
}
});
// our function to init an empty item
function initEmptyItem(key) {
return Immutable({
key,
isLoading: false,
isLoaded: false,
items: []
})
}
mergeWithCurrent
will help you reduce this boilerplate:
import { mergeWithCurrent } from "redux-rsi";
export default createReducer(Immutable({}), {
onSomethingHappened(state, data) {
return mergeWithCurrent(
state, //the state tree
data.key, //the key of the item we want to merge
data, //the new data to merge with any existing data
initEmptyItem //an optional init function if the data doesn't already exist (if none passed, defaults to {})
);
}
});
The above two examples are equivalent. The initFn
will receive the state key
as its only argument.
registerReducer(name, reducer)
In order to effectively encapsulate the state tree structure from the rest of your app, it is advantageous to colocate your selector functions with your reducers. As your app grows, using this approach you will find that you end up with a massive root reducer file filled with repetetive selector functions just drilling down the state tree to child reducers. The static definition of the reducer/state tree does not scale. If you find yourself in this situation, it can help to introduce a reducer registry to implement the redux module concept.
registerReducer
registers a reducer function with the registry with a given name so that any selector functions defined with that reducer will know where they are located within the state tree. This effectively inverts the control of the structure of the state tree. Instead of the application consuming the reducers defining what the state tree looks like, it is the reducers themselves that are defining where they will live within the tree.
// reducer.js
import { registerReducer } from "redux-rsi";
const REDUCER_NAME = "app/counter";
const reducer = (state, { type }) => {
if (state === undefined) return 0;
if (type === "INCREMENT") return state + 1;
if (type === "DECREMENT") return state - 1;
return state;
};
registerReducer(REDUCER_NAME, reducer);
export const getCount = state => state[REDUCER_NAME];
createStore([initialState], [enhancer], [combineFn])
This API is meant to mirror the official redux createStore
API except that it uses the reducers from the reducer registry rather than accepting a reducer
parameter.
All parameters are optional and match the behavior of the official API.
The combineFn
, if specified, will override the function to use to combine the reducers together. By default, if not specified, it will use the combineReducers
function from redux. Depending on the needs of your app, you may want to use a different function (e.g. combineReducers
from redux-seamless-immutable).
// app.js
import { createStore } from "redux-rsi";
import { getCount } from "./reducer"; // using the example above from registerReducer
const store = createStore();
this.store.dispatch({ type: "INCREMENT" });
console.log(getCount(this.store.getState())); // 1
Any reducers registered after the store has been created will cause the store to reload and add the new reducers dynamically. This allows for incremental loading and code splitting of reducers.
Contributing
Clone the repo and run yarn
to install dependencies.
Linting
To run the linter:
yarn run lint
and to automatically fix fixable errors:
yarn run lint --fix
Testing
The tests are written BDD-style using Mocha. To run them locally:
yarn test
To debug the tests:
yarn test --debug-brk
You can then use Visual Studio Code (or whatever other debugger you desire) to debug through the tests.