Scaling Redux Reducers with Convention-Based Approaches

React and Redux have taken the frontend world by storm over the last several years. Redux‘s predictable state management model and support for middleware have made it a popular choice for complex applications. However, as codebases grow, traditional Redux reducer patterns like switch statements can lead to bloated, hard-to-maintain code.

In this deep dive, we‘ll explore an alternative approach known as "reducers by convention" that can help keep your Redux code lean and scalable as your application grows.

Understanding Redux Reducers

At the core of Redux is the idea of a reducer function. Reducers specify how the application‘s state changes in response to dispatched actions. They are pure functions that take the previous state and an action and return the next state:

(state, action) => newState

Here‘s a simple counter reducer:

function counter(state = 0, action) {
  switch (action.type) {
    case ‘INCREMENT‘:
      return state + 1;
    case ‘DECREMENT‘:
      return state - 1;
    default:
      return state;
  }
}

As the application grows, reducers often need to handle more action types, leading to larger switch statements:

function todoApp(state = initialState, action) {
  switch (action.type) {
    case ‘ADD_TODO‘:
      return { 
        ...state,
        todos: [
          ...state.todos, 
          {
            text: action.text,
            completed: false
          }  
        ]
      };
    case ‘TOGGLE_TODO‘:
      return {
        ...state, 
        todos: state.todos.map((todo, index) => {
          if (index === action.index) {
            return { ...todo, completed: !todo.completed };
          }
          return todo;
        }) 
      };
    case ‘SET_VISIBILITY_FILTER‘:
      return { 
        ...state,
        visibilityFilter: action.filter
      };
    default:
      return state;  
  }
}

The issues with this approach start becoming apparent:

  • The reducer is handling multiple concerns (todos and visibilityFilter), breaking the Single Responsibility Principle.
  • Each case is tightly coupled to the rest of the function.
  • Adding new cases becomes increasingly difficult and error-prone.

The Convention-Based Approach

Reducers by convention seek to address these issues through a simple idea: what if, instead of a switch statement, we map action types to handler functions?

const reducerMap = {
  ADD_TODO: addTodo,
  TOGGLE_TODO: toggleTodo,
  SET_VISIBILITY_FILTER: setVisibilityFilter
};

Each action type maps to a function that handles that specific action. These "setter" functions take the current state and the action object and return the new state:

function addTodo(state, action) {
  return {
    ...state,
    todos: [
      ...state.todos,
      {
        text: action.text,
        completed: false
      }
    ]
  };
}

The redux-actions library provides a utility called handleActions that lets us create a reducer from a map of action types to handler functions:

import { handleActions } from ‘redux-actions‘;

const initialState = {
  todos: [],
  visibilityFilter: ‘SHOW_ALL‘ 
};

const reducer = handleActions(
  {
    ADD_TODO: addTodo,
    TOGGLE_TODO: toggleTodo,
    SET_VISIBILITY_FILTER: setVisibilityFilter
  },
  initialState
);

This approach has several benefits:

  • Each action handler is focused and decoupled, making the code more modular and easier to reason about.
  • Adding new action types is as simple as writing a new handler function.
  • The initial state can be defined separately from the handlers.

A Real-World Example

To see the impact of this approach, let‘s look at a more realistic example. Imagine we‘re building an e-commerce application with a product catalog reducer that needs to handle several action types:

const initialState = {
  products: [],
  loading: false,
  error: null
};

function productCatalog(state = initialState, action) {
  switch (action.type) {
    case ‘FETCH_PRODUCTS_REQUEST‘:
      return {
        ...state,
        loading: true,
        error: null
      };
    case ‘FETCH_PRODUCTS_SUCCESS‘:
      return {
        ...state,
        loading: false,
        products: action.products
      };
    case ‘FETCH_PRODUCTS_FAILURE‘:
      return {
        ...state,
        loading: false,
        error: action.error
      };
    case ‘ADD_PRODUCT‘:
      return {
        ...state,
        products: [...state.products, action.product]
      };
    case ‘UPDATE_PRODUCT‘:
      return {
        ...state,
        products: state.products.map(product =>
          product.id === action.product.id ? action.product : product  
        )
      };
    case ‘REMOVE_PRODUCT‘:
      return {
        ...state,  
        products: state.products.filter(
          product => product.id !== action.productId
        )
      };  
    default:
      return state;
  }
}

Even in this fairly simple example, the issues with the switch statement approach are evident. Let‘s refactor this to use reducers by convention:

import { handleActions } from ‘redux-actions‘;

const initialState = {
  products: [],
  loading: false,
  error: null  
};

function fetchProductsRequest(state, action) {
  return {
    ...state,
    loading: true,
    error: null
  };
}

function fetchProductsSuccess(state, action) {
  return {
    ...state,
    loading: false,
    products: action.products
  };
}

function fetchProductsFailure(state, action) {
  return {
    ...state,
    loading: false, 
    error: action.error
  };
}

function addProduct(state, action) {
  return {
    ...state,
    products: [...state.products, action.product]
  };  
}

function updateProduct(state, action) {
  return {
    ...state,
    products: state.products.map(product =>
      product.id === action.product.id ? action.product : product
    ) 
  };
}

function removeProduct(state, action) {
  return {
    ...state,
    products: state.products.filter(product => product.id !== action.productId)  
  };
}

const productCatalog = handleActions(
  {
    FETCH_PRODUCTS_REQUEST: fetchProductsRequest,
    FETCH_PRODUCTS_SUCCESS: fetchProductsSuccess,
    FETCH_PRODUCTS_FAILURE: fetchProductsFailure,
    ADD_PRODUCT: addProduct,
    UPDATE_PRODUCT: updateProduct,
    REMOVE_PRODUCT: removeProduct
  },
  initialState
);

With this refactor, each action handler is clearly defined and decoupled from the others. The reducer itself is just a mapping between action types and handlers. This makes the code much easier to understand and maintain.

The Power of redux-actions

The redux-actions library provides several utilities that simplify the process of creating and working with Redux actions and reducers.

In addition to handleActions, it provides createAction for defining action creators:

import { createAction } from ‘redux-actions‘;

export const addTodo = createAction(‘ADD_TODO‘);

This creates a function that, when called, returns an action object with the given type:

addTodo(‘Learn Redux‘);
// { type: ‘ADD_TODO‘, payload: ‘Learn Redux‘ }

The library also provides utilities for working with promises, reducing boilerplate when handling asynchronous actions:

import { createAction } from ‘redux-actions‘;

export const fetchTodos = createAction(‘FETCH_TODOS‘, async () => {
  const response = await fetch(‘/todos‘);
  return response.json();
});

When this action creator is called, it dispatches a FETCH_TODOS_PENDING action, followed by either a FETCH_TODOS_FULFILLED action with the resolved value, or a FETCH_TODOS_REJECTED action with the rejection reason.

Testing and Performance

Reducers by convention are particularly easy to test, as each handler function can be tested in isolation:

import { addTodo } from ‘./reducers‘;

test(‘addTodo adds a new todo‘, () => {
  const initialState = { todos: [] };
  const action = { type: ‘ADD_TODO‘, text: ‘Learn Redux‘ };
  const expectedState = { 
    todos: [{ text: ‘Learn Redux‘, completed: false }]
  };

  expect(addTodo(initialState, action)).toEqual(expectedState);
});

In terms of performance, the convention-based approach can actually be more efficient than a switch statement. In a switch, the JavaScript engine needs to evaluate each case until it finds a match. With the mapping approach, the appropriate handler function can be looked up directly.

Best Practices

When using reducers by convention, there are a few best practices to keep in mind:

  • Keep your handler functions pure. They should not mutate the state directly, but instead return a new state object.

  • If you need to share logic between handlers, extract it into a separate utility function.

  • Consider using a library like immer to simplify working with immutable state.

  • Use action constants instead of strings to avoid typos and make refactoring easier.

  • Organize your reducer files by domain concept (e.g., todosReducer.js, visibilityFilterReducer.js).

Alternatives and Tooling

Redux Toolkit is an official opinionated toolset for efficient Redux development. It includes utilities for creating actions and reducers, and enforces some best practices by default.

The createSlice function in particular is a powerful tool for defining reducers:

import { createSlice } from ‘@reduxjs/toolkit‘;

const todosSlice = createSlice({
  name: ‘todos‘,
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      state.push({
        text: action.payload,
        completed: false
      });
    },
    toggleTodo: (state, action) => {
      const todo = state[action.payload];
      todo.completed = !todo.completed;
    }
  }
});

export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;

This creates a slice of the Redux state with the specified name and generates action creators and action types automatically.

While Redux Toolkit is a great choice for many applications, the convention-based approach we‘ve discussed can still be a good fit, especially if you need more flexibility in how your reducers are defined.

Conclusion

As we‘ve seen, the traditional switch statement approach to writing Redux reducers can quickly lead to unwieldy and hard-to-maintain code. Reducers by convention offer a more scalable alternative by mapping action types to focused handler functions.

This approach, enabled by libraries like redux-actions, results in reducers that are more modular, easier to reason about, and simpler to test. While it‘s not the only way to write Redux reducers (and may not be the best choice for every situation), it‘s a powerful tool to have in your toolbox as your application grows in size and complexity.

As with any architectural decision, the key is to understand the tradeoffs and choose the approach that best fits your needs. But if you find yourself struggling with sprawling switch statements, give reducers by convention a try. Your future self (and your fellow developers) will thank you.

Similar Posts