Where do I put my business logic in a React-Redux application?

TL;DR (Summary)

  • action creators
  • reducers
  • thunks
  • sagas
  • epics
  • effects
  • custom middleware
  • redux-logic — a new approach

The journey to enlightenment

What do I mean by business logic?

  • validation, verification, authorization
  • transformation, augmentation, applying defaults
  • processing — asynchronous orchestration

Dispatching from action creators

const fetchUser = (dispatch, id) => {
dispatch({ type: USER_FETCH, payload: id });
axios.get(`https://server/user/${id}`)
.then(resp => resp.data)
.then(user => dispatch({ type: USER_FETCH_SUCCESS,
payload: user }))
.catch(err => {
console.error(err); // log since might be a render err
dispatch({ type: USER_FETCH_FAILED,
payload: err,
error: true });
});
};
  • simple and focused code, not much additional ceremony
  • No easy way to cancel pending requests or only take the latest if multiple requests are made
  • No access to the global state to make decisions with. You only have access to whatever props or component state that you pass in. This might mean requiring extra data to be provided to a component that normally wouldn’t be needed for rendering but is solely needed for the business logic. Any time the business logic changes it might require new additional data to be passed down possibly affecting many components in the process.
  • No interception. I also have no easy way to create business logic that applies across many actions, so if I wanted to augment all actions to have a timestamp or unique ID, I’d have to include a call to that code in all action creators.
  • Testing components and fat action creators may require running the code (possibly mocked).

Reducers

  • It’s easy to perform some logic inside of the reducer
  • Business logic can choose whether to apply the state change
  • Conflates the business logic with the state updates
  • Only synchronous tasks
  • No ability to replace or dispatch a different error action
  • Access is limited to partial state if reducers are being combined
  • No global interception of actions

Thunks — functions and promise

const fetchUser = id => (dispatch, getState) => {
dispatch({ type: USER_FETCH, payload: id });
axios.get(`https://server/user/${id}`)
.then(resp => resp.data)
.then(user => dispatch({ type: USER_FETCH_SUCCESS,
payload: user }))
.catch(err => {
console.error(err); // log since might be a render err
dispatch({ type: USER_FETCH_FAILED,
payload: err,
error: true });
});
};
  • Simple and focused
  • Access global state easily
  • Slightly simpler redux mapDispatchToProps for binding action creators
  • No easy way to cancel pending requests or only take the latest if multiple requests are made
  • No interception. I also have no easy way to create business logic that applies across many actions, so if I wanted to augment all actions to have a timestamp or unique ID, I’d have to include a call to that code in all action creators.
  • Testing components and thunked action creators may require running the code (possibly mocked). When you have a thunk (function or promise) you don’t know what it does unless you execute it.

Sagas — redux-saga

// this action creator will be bound to dispatch
const fetchUser = id => ({ type: USER_FETCH, payload: id });
// saga code elsewhere
function* watchUserFetch() {
yield* takeLatest(“USER_FETCH”, fetchUser);
}
function* fetchUser(action) {
try {
const user =
yield axios.get(`http://server/user/${action.payload}`);
yield put({ type: USER_FETCH_SUCCESS, payload: user.data });
}
catch(err) {
yield put({ type: USER_FETCH_FAILED, payload: err, error: true });
}
}
// rootSaga.js
export default function* rootSaga() {
yield fork(watchUserFetch);
yield fork(…);
// etc
}
// this action creator will be bound to dispatch
const fetchUser = id => ({ type: USER_FETCH, payload: id });
// saga code elsewhere
function* watchUserFetch() {
while ( yield take(USER_FETCH) ) {
const userFetchTask = yield(fork(fetchUser));
// wait for cancel action
yield take (USER_FETCH_CANCEL);
// we have the cancel action, cancel task
yield cancel(userFetchTask);
}
}
function* fetchUser(action) {
try {
const user = yield fetch(`http://server/user/${action.payload}`);
yield put({ type: USER_FETCH_SUCCESS, payload: user });
}
catch(err) {
yield put({ type: USER_FETCH_FAILED,
payload: err,
error: true });
}
finally {
if (yield cancelled()) { // if was cancelled
yield put({ type: USER_FETCH_CANCELLED });
}
}
}
// rootSaga.js
export default function* rootSaga() {
yield fork(watchUserFetch);
yield fork(…);
// etc
}
  • ES6 generators allow for limiting and cancellation
  • Action creators now only dispatch simple objects so testing components is easy. Generator code can be tested independently.
  • Asynchronous orchestration looks like synchronous code via yield
  • ES6 generators are not commonly well understood by many developers
  • Fair amount of code to setup watch loops and implement cancellation
  • No interception — sagas always run after actions have been given to the reducers

Epics redux-observable

// this action creator will be bound to dispatch
const fetchUser = id => ({ type: USER_FETCH, payload: id });
// userEpics.js
const fetchUserEpic = action$ =>
action$.ofType(FETCH_USER)
.mergeMap(action =>
Rx.Observable.create(obs => {
axios.get(`https://server/user/${action.payload}`)
.then(resp => resp.data)
.then(user => {
obs.next({ type: FETCH_USER_SUCCESS, payload: user });
obs.complete();
})
.catch(err => {
obs.next({ type: FETCH_USER_FAILED,
payload: err,
error: true });
obs.complete();
});
})
.takeUntil(action$.ofType(USER_FETCH_CANCELLED)));
// rootEpic.js — export rootEpic used with createEpicMiddleware()
export default combineEpics(
fetchUserEpic,
fooEpics
);
// this action creator will be bound to dispatch
const fetchUser = id => (
{ type: USER_FETCH, payload: id }
);
// userEpics.js
const fetchUserEpic = action$ =>
action$.ofType(FETCH_USER)
.mergeMap(action =>
ajax.getJSON(`https://a/${action.payload}`)
.map(user => ({ type: FETCH_USER_SUCCESS,
payload: user }))
.catch(err => ({ type: FETCH_USER_FAILED,
payload: err,
error: true }))
.takeUntil(
action$
.ofType(USER_FETCH_CANCELLED)));
// rootEpic.js - export rootEpic used with the createEpicMiddleware()
export default combineEpics(
fetchUserEpic,
fooEpics
);
  • Observables bring the power of cancellation and limiting
  • RxJS Observables can be difficult to learn and grasp.
  • A good portion of the code is now there simply for setting up the observable behavior
  • No interception — actions are sent through reducers first before being handed to epics
  • Testing can require a bit of setup

Effects — redux-loop

const fetchUser = id =>
axios.get(`https://server/user/${id}`)
.then(resp => resp.data)
.then(user => ({ type: USER_FETCH_SUCCESS, payload: user }))
.catch(err => ({ type: USER_FETCH_FAILED,
payload: err,
error: true }));
function reducer(state, action) {
switch(action.type) {
case USER_FETCH :
return loop(
state.set(‘status’, ‘fetching’),
Effects.promise(fetchUser, action.payload)
);
}
}
  • Effect code is pretty clean, not much extra ceremony
  • Everything is fairly testable in isolation since the effects are returned as structures
  • Reducers no longer return just state but possibly effects
  • No cancellation or limiting functionality
  • No global interception of actions

Custom Middleware

const fetchUserMiddleware = store => next => action => {
if (action.type === FETCH_USER) {
axios.get(`https://server/user/${action.payload}`)
.then(resp => resp.data)
.then(user => store.dispatch({ type: USER_FETCH_SUCCESS,
payload: user }))
.catch(err => {
console.error(err); // log since might be a render err
store.dispatch({ type: USER_FETCH_FAILED,
payload: err,
error: true });
});
return next(action); // pass the original
} else { // pass other actions
return next(action);
}
};
  • Full power
  • All custom code — you implement all functionality
  • No safety net, if your code errors, it could stop all future actions
  • Remember to pass on actions that you aren’t concerned with
  • Need to mock things to test your code

redux-logic — A new approach

  • Declarative functionality for cancellation and limiting. The power of observables without the need to write observable code. Hide that complexity from the user and just make it configurable.
  • Ability to intercept some or all actions to provide validation, verification, authorization, and transformation
  • Ability to do async processing and dispatching
  • Ability to create long running cancelable subscriptions for streaming updates
  • Little to no ceremony — code I write should be focused on performing business logic
  • Easy testing in isolation — be able to run my code without a bunch of hassle or setup
// bound to dispatch
const fetchUserAction = id => ({ type: USER_FETCH, payload: id });
// in userLogic.js
const fetchUserLogic = createLogic({
// declarative behavior
type: USER_FETCH, // filter for actions of this type
cancelType: USER_FETCH_CANCEL, // cancel on this type
latest: true, // only provide the latest if fired many times
// execution phase hooks: validate, transform, process
// implement one or more of these
process({ getState, action }, dispatch, done) {
axios.get(`https://server/user/${action.payload}`)
.then(resp => resp.data)
.then(user => dispatch({ type: USER_FETCH_SUCCESS,
payload: user }))
.catch(err => {
console.error(err); // log since might be a render err
dispatch({ type: USER_FETCH_FAILED,
payload: err,
error: true });
})
.then(() => done()); // call when done dispatching
}
});
const fetchUserLogic = createLogic({
// declarative behavior
type: USER_FETCH, // filter for actions of this type
cancelType: USER_FETCH_CANCEL, // cancel if action is dispatched
latest: true, // only provide the latest if fired many times
processOptions: { // options for process hook, default {}
dispatchReturn: true // dispatch from resolved/rejected promise
successType: USER_FETCH_SUCCESS, // use action type for success
failType: USER_FETCH_FAILED // use action type for errors
},
// No need to dispatch since we are using the returned promise
// and automatically applying the actions to the raw values which
// get mapped to the action payload
process({ getState, action }) {
return axios.get(`https://server/user/${action.payload}`)
.then(resp => resp.data);
}
});
const userVerifyLogic = createLogic({
type: [USER_ADD, USER_UPDATE] // filter to only these types
validate({ getState, action }, allow, reject) {
const user = action.payload;
const state = getState();
// can perform checks on anything in global state
if (!user.name) {
reject({ type: USER_ERROR,
payload: new Error(‘missing name’),
error: true });
} else { // valid user
allow(action);
}
}
});
const addTimestampAndUniqueIDLogic = createLogic({
type: '*', // apply to all actions
transform({ getState, action }, next) {
const existingMeta = action.meta || {};
const meta = {
…existingMeta,
timestamp: Date.now(),
uniqueId: shortId.generate()
};
// now simply call next with the augmented action
next({
…action,
meta
});
}
});

The full redux-logic API

const fooLogic = createLogic({
// filtering/canceling
type, // required str, regex, array of str/regex, use '*' for all
cancelType, // string, regex, array of strings or regexes
// limiting - optionally define one of these
latest, // only take latest, default false
debounce, // debounce for N ms, default 0
throttle, // throttle for N ms, default 0
// Put your business logic into one or more of these
// execution phase hooks.
//
// Note: If you provided any optional dependencies in your
// createLogicMiddleware call, then these will be provided to
// your code in the first argument along with getState and action
validate({ getState, action }, allow, reject) {
// run your verification logic and then call allow or reject
// with the action to pass along. You may pass original action
// or a modified/different action. Use undefined to prevent any
// action from being propagated like allow() or reject()
allow(action); // OR reject(action)
}),
// alias for the validate hook
transform({ getState, action }, next) {
// perform any transformation and provide the new action to next
next(action);
}),
// options influencing the process hook, defaults to {}
processOptions: {
// dispatch resolved/rejected promise/observable from return
dispatchReturn: false, // default false
// string or action creator fn wrapping dispatched value
successType: undefined, // default undefined
// string or action creator wrapping rejected or thrown errors
failType: undefined // default undefined
},
// perform async processing and dispatching
process({ getState, action, cancelled$ }, dispatch, done) {
// Perform your processing then call dispatch with an action
// then call done() when finished dispatching
// See other ways to use process in advanced API docs
dispatch(myNewAction);
done(); // call when done dispatching
})
});
const logicMiddleware = createLogicMiddleware(
logic, // array of logic items
deps // optional injected deps/config, supplied to logic
);
// dynamically add logic later at runtime, keeping logic state
logicMiddleware.addLogic(newLogic);
// replacing logic, logic state is reset but in-flight logic
// should still complete properly
logicMiddleware.replaceLogic(replacementLogic);

--

--

--

Fullstack Web Developer, Node.js, Javascript, HTML5, Trainer, Publisher, Entrepreneur, Husband, Father, Genesis Ministry, Catholic Renewal, Skier, Conservative

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Jeff Barczewski

Jeff Barczewski

Fullstack Web Developer, Node.js, Javascript, HTML5, Trainer, Publisher, Entrepreneur, Husband, Father, Genesis Ministry, Catholic Renewal, Skier, Conservative

More from Medium

Post Save hooks, do it right.

React-Query Brief

How I Built Zoe Drug Store

Complex Systems: Top N users writing comment indicator for post — Part 3