Yet another guide to reduce boilerplate in your Redux (NGRX) app

If you‘ve worked on a large Redux or NGRX app, you‘ve likely noticed that the codebase can become cluttered with repetitive, tedious code – what we call "boilerplate". Writing and maintaining all this boilerplate is a productivity drain and a source of potential bugs.

The question is, how can we reduce this boilerplate without sacrificing the benefits of a Redux architecture? Based on my experience as a full-stack developer, I‘ll share some proven strategies in this guide. While I‘ll focus on Redux, these same concepts apply to NGRX in the Angular world.

What exactly is boilerplate code?

In the context of Redux/NGRX, boilerplate refers to the repetitive code required to perform basic tasks like defining actions and action creators, writing reducers, selecting state data, and handling async logic.

For a simple feature like fetching a list of todos from an API, you typically need to write:

  1. Action types (e.g. FETCH_TODOS_REQUEST, FETCH_TODOS_SUCCESS, FETCH_TODOS_FAILURE)
  2. Action creators for each action type
  3. A reducer to handle those actions and update state
  4. Async logic (middleware like redux-thunk or a library like redux-saga) to make the API call and dispatch actions
  5. Selector functions to retrieve the todos from the state

As an app grows in features and complexity, the boilerplate compounds and the ratio of signal to noise in the codebase deteriorates. Developers spend more time wiring up Redux/NGRX and less time writing meaningful application logic. The resulting code is harder to understand, maintain and test.

However, we shouldn‘t throw out Redux/NGRX because of boilerplate. A Redux architecture provides important benefits like deterministic state management, reproducible states for debugging, and decoupled state logic from UI. We just need some strategies to minimize the boilerplate tax.

Strategies to reduce Redux/NGRX boilerplate

Here are some ways I‘ve successfully cut down on boilerplate in production Redux/NGRX apps while retaining the key benefits of the architecture:

1. Use action creators and action types effectively

Instead of inlining action objects everywhere, define each action type as a constant and create a corresponding action creator function. This makes action dispatching more concise and intention-revealing.

For example, instead of:

dispatch({ type: ‘FETCH_TODOS_REQUEST‘ })

Do:

const FETCH_TODOS_REQUEST = ‘FETCH_TODOS_REQUEST‘

function fetchTodosRequest() {
  return { type: FETCH_TODOS_REQUEST }
}

dispatch(fetchTodosRequest())

Libraries like redux-actions can further reduce action boilerplate by auto-generating action creators from an object mapping of action types to payload creators.

2. Simplify reducers with reducer composition

Reducers often contain a lot of duplication in the form of action type checking (switch statements) and immutable updates. We can abstract this away by composing smaller utility reducers together.

For example, instead of:

function todosReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_TODOS_REQUEST:
      return {
        ...state, 
        isLoading: true
      } 
    case FETCH_TODOS_SUCCESS:
      return {
        ...state,
        isLoading: false,
        todos: action.payload  
      }
    case FETCH_TODOS_FAILURE:
      return {
        ...state,
        isLoading: false, 
        error: action.payload
      }
    default:
      return state
  }
}

We can compose simpler reducers for each concern:

const isLoadingReducer = createReducer(false, {
  [FETCH_TODOS_REQUEST]: () => true,
  [combineActions(FETCH_TODOS_SUCCESS, FETCH_TODOS_FAILURE)]: () => false
})

const todosReducer = createReducer([], {
  [FETCH_TODOS_SUCCESS]: (state, action) => action.payload
})

const errorReducer = createReducer(null, {
  [FETCH_TODOS_FAILURE]: (state, action) => action.error
})

const rootReducer = combineReducers({
  isLoading: isLoadingReducer,
  todos: todosReducer,
  error: errorReducer,  
})

The createReducer helper from redux-toolkit lets us map action types to state transformations without explicit switch statements and manual immutable updates. We can further cut down on boilerplate using utilities like combineActions from redux-actions.

3. Abstract async logic with redux-saga or redux-observable

Much of Redux boilerplate comes from handling async actions like API requests. Middleware like redux-thunk helps, but still requires a lot of repetitive dispatching and error handling.

Abstracting async flows into sagas (using redux-saga) or epics (using redux-observable) lets us write more concise and declarative async logic. We can define common patterns like API requests or retries once and reuse them across the app.

For example, a saga for fetching todos might look like:

function* fetchTodosSaga() {
  try {
    const todos = yield call(api.fetchTodos)
    yield put(fetchTodosSuccess(todos))
  } catch (e) {
    yield put(fetchTodosFailure(e))
  }
}

export function* todosSaga() {
  yield takeLatest(FETCH_TODOS_REQUEST, fetchTodosSaga)
}

Compared to thunks, sagas are easier to test, can be composed together, and eliminate a lot of boilerplate around promise and error handling.

4. Leverage selectors to minimize redundant state transformations

Selectors are functions that take the Redux/NGRX state and return some derived data. They help abstract the shape of the state from the components that use it.

A common anti-pattern is to select the same derived data in multiple components by duplicating the transformation logic (e.g. filtering, sorting, etc). This leads to a lot of redundant and brittle state accessing code.

Instead, we can define selectors once and reuse them across the app. Libraries like reselect can further optimize selectors by memoizing (caching) the results.

For example, instead of:

const CompletedTodos = ({ todos }) => {
  const completedTodos = todos.filter(todo => todo.completed)
  return <TodoList todos={completedTodos} />  
}

const TodoCount = ({ todos }) => {
  const completedTodos = todos.filter(todo => todo.completed)
  return <div>Completed {completedTodos.length} todos</div>
}

We can define a selector:

const selectCompletedTodos = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.completed)
)

And use it in components:

const CompletedTodos = ({ completedTodos }) => {
  return <TodoList todos={completedTodos}>
}

const TodoCount = ({ completedTodos }) => {
  return <div>Completed {completedTodos.length} todos</div>
}

const mapState = state => ({
  completedTodos: selectCompletedTodos(state)  
})

connect(mapState)(CompletedTodos)
connect(mapState)(TodoCount)

This makes state access more declarative, DRY, and efficient.

5. Automate code generation where possible

Some Redux/NGRX boilerplate is so formulaic that it can be codified into snippets or templates and auto-generated.

For example, you can create a script that takes an initial state interface and generates corresponding action types, creators, and a reducer with TypeScript types. Or use a tool like redux-cli to scaffold out common Redux patterns.

The generated code will still need some hand-tuning, but it can save a lot of keystrokes and mental overhead. Plus it helps enforce consistent patterns across the codebase.

6. Organize code to minimize cognitive overhead

How you structure your Redux/NGRX-related code has a big impact on boilerplate and maintainability. Sprawling your action, reducer, and selector logic across many files in different folders leads to a lot of jumping around and mental juggling.

I‘ve found the ducks pattern (and its variants like re-ducks) to work well. It colocates all Redux artifacts for a feature in a single file or adjacent files. This makes features more self-contained, easier to reason about, and less boilerplate-y to work with.

For example, here‘s what an "auth" duck might look like:

// auth/auth.actions.ts
export const LOGIN_REQUEST = ‘auth/LOGIN_REQUEST‘
export const LOGIN_SUCCESS = ‘auth/LOGIN_SUCCESS‘
export const LOGIN_FAILURE = ‘auth/LOGIN_FAILURE‘

export const loginRequest = (credentials) => ({  
  type: LOGIN_REQUEST,
  payload: credentials
})

export const loginSuccess = (user) => ({
  type: LOGIN_SUCCESS,
  payload: user  
})

export const loginFailure = (error) => ({
  type: LOGIN_FAILURE,
  payload: error
})


// auth/auth.reducer.ts 
const initialState = {
  user: null,
  isLoggingIn: false,
  error: null
}

export default function authReducer(state = initialState, action) {
  switch (action.type) {
    case LOGIN_REQUEST: 
      return { ...state, isLoggingIn: true, error: null }
    case LOGIN_SUCCESS:
      return { ...state, isLoggingIn: false, user: action.payload }
    case LOGIN_FAILURE:
      return { ...state, isLoggingIn: false, error: action.payload }
    default:
      return state
  }
}


// auth/auth.selectors.ts
export const selectIsLoggingIn = (state) => state.auth.isLoggingIn
export const selectUser = (state) => state.auth.user
export const selectError = (state) => state.auth.error

// auth/index.ts 
import authReducer from ‘./auth.reducer‘
export * from ‘./auth.actions‘
export * from ‘./auth.selectors‘
export default authReducer

With this structure, each feature is encapsulated, avoids circular dependencies, and minimizes the need to import things from far-flung places. It‘s as close to "locality of behavior" as we can get in Redux.

When not to reduce boilerplate

All this said, there is a point of diminishing returns with abstracting Redux/NGRX boilerplate. If you try to eliminate all duplication, you‘ll end up with a highly indirected architecture that‘s hard to follow.

The goal should be to judiciously identify and eliminate the most egregious boilerplate while retaining enough "straightforward" Redux/NGRX code that it‘s still recognizable and debuggable. As always, strive for a pragmatic balance.

Conclusion and key takeaways

While Redux/NGRX boilerplate is a fact of life, we have many tools and patterns at our disposal to minimize it:

  1. Consistently use action creators and leverage libraries to generate them
  2. Compose small, focused reducers together using utilities like combineReducers and createReducer
  3. Delegate complex async flows to redux-saga or redux-observable
  4. Share selectors to deduplicate and optimize state access
  5. Codegen the most formulaic parts of Redux/NGRX
  6. Organize code to emphasize feature cohesion and discoverability

By applying these strategies to eliminate accidental complexity, your Redux/NGRX features will be leaner, more maintainable, and more pleasant to work with. You‘ll spend less time and mental energy on Redux/NGRX plumbing and more on the unique parts of your app. And that‘s the dream, isn‘t it?

I hope this guide saves you some keystrokes and headaches in your Redux/NGRX journey. Feel free to reach out with your experiences and insights — the more we share and learn from each other, the better the ecosystem becomes.

Happy coding! May your reducers be concise and your sagas bountiful.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *