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

This is a question that we have all struggled with in building applications. It is an important question because it influences the architecture of our code and how well our app will absorb new features and complexity as it naturally grows during its lifetime.

TL;DR (Summary)

There are many approaches that will work but it is important to understand the tradeoffs to pick the best solution. I discuss some of the key highlights for each of these options.

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

The journey to enlightenment

Here is my journey to trying to find the best approach to structuring business logic in my React + Redux app.

What do I mean by business logic?

According to wikipedia, “In computer software, business logic or domain logic is the part of the program that encodes the real-world business rules that determine how data can be created, displayed, stored, and changed. It is contrasted with the remainder of the software that might be concerned with lower-level details of managing a database or displaying the user interface, system infrastructure, or generally connecting various parts of the program.”

  • transformation, augmentation, applying defaults
  • processing — asynchronous orchestration

Dispatching from action creators

The first obvious choice when considering where to put business logic is simply to try to perform all of the work in an action creator. You can simply perform your work and then dispatch when you have results.

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 });
});
};
  • 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

Another approach that only works for synchronous business logic is to simply perform it in your reducers.

  • Business logic can choose whether to apply the state change
  • 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

The next suggestion is to use one of the variety of thunk middleware available. You to pass a function or promise from your action creator that is intercepted by a special thunk middleware and executed.

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 });
});
};
  • Access global state easily
  • Slightly simpler redux mapDispatchToProps for binding action creators
  • 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

Sagas introduce a way to use ES6 generators to run additional business logic and async code. Your action creators simply trigger the saga by dispatching a simple action that the saga is listening for.

// 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
}
  • 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
  • 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

Epics are the redux-observable approach to Sagas. They use RxJS Observables to allow for filtering, limiting, cancellation, and asynchronous orchestration.

// 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
);
  • 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

redux-loop is a way to deal with side effects using an elm inspired approach. The idea is that your reducer can return a structure of effects to run, then the redux-loop middleware runs it and dispatches the results.

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)
);
}
}
  • Everything is fairly testable in isolation since the effects are returned as structures
  • No cancellation or limiting functionality
  • No global interception of actions

Custom Middleware

Custom middleware can pretty much do anything and that’s what we’d expect that since that is what all of these other solutions are built on.

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);
}
};
  • 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

After learning all of these different ways, I still wasn’t happy with finding one approach that I could use for all of my business logic.

  • 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

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