How to nest Redux Toolkit reducers for a single property

I’m migrating a codebase from vanilla Redux to Redux Toolkit. I’m trying to find a good way to nest reducers created with createReducer just for a single property.

Let’s say I have a setup like the following contrived example with a user reducer and a friends reducer nested under it. The user can change their name, which only affects itself, and also add and remove their friends, which affects itself and its friends array property that is managed by the friends reducer.

const CHANGE_NAME = "CHANGE_NAME";
const ADD_FRIEND = "ADD_FRIEND";
const REMOVE_ALL_FRIENDS = "REMOVE_ALL_FRIENDS";

const initialState = {
  username: "",
  email: "",
  lastActivity: 0,
  friends: [],
};

const user = (state = initialState, action) => {
  switch (action.type) {
    case CHANGE_NAME: {
      const { newName, time } = action.payload;

      return {
        ...state,
        name: newName,
        lastActivity: time,
      };
    }
    case ADD_FRIEND:
    case REMOVE_ALL_FRIENDS: {
      const { time } = action.payload;

      return {
        ...state,
        friends: friends(state.friends, action),
        lastActivity: time,
      };
    }
    default: {
      return {
        ...state,
        friends: friends(state.friends, action),
      };
    }
  }
};

const friends = (state = initialState.friends, action) => {
  switch (action.type) {
    case ADD_FRIEND: {
      const { newFriend } = action.payload;

      return [...state, newFriend];
    }
    case REMOVE_ALL_FRIENDS: {
      return [];
    }
    default: {
      return state;
    }
  }
};

To note:

  • friends is necessarily correlated with user, so I had decided to nest it within its state slice.
  • user manually calls the friends reducer to calculate the friends slice of state with possibly overlapping action types, and only for that one friends property.

I am now trying to refactor this with Redux Toolkit createReducers. My first attempt was the following:

import { createReducer } from "@reduxjs/toolkit";

const CHANGE_NAME = "CHANGE_NAME";
const ADD_FRIEND = "ADD_FRIEND";
const REMOVE_ALL_FRIENDS = "REMOVE_ALL_FRIENDS";

const initialState = {
  username: "",
  email: "",
  lastActivity: 0,
  friends: [],
};

const user = createReducer(initialState, (builder) => {
  builder
    .addCase(CHANGE_NAME, (state, action) => {
      const { newName, time } = action.payload;

      state.name = newName;
      state.lastActivity = time;
    })
    .addMatcher((action) => action.type === ADD_FRIEND || action.type === REMOVE_ALL_FRIENDS),
    (state, action) => {
      const { time } = action.payload;
      state.lastActivity = time;
      state.friends = friends(state.friends, action);
    };
});

const friends = createReducer(initialState, (builder) => {
  builder
    .addCase(ADD_FRIEND, (state, action) => {
      const { newFriend } = action.payload;

      state.push(newFriend);
    })
    .addCase(REMOVE_ALL_FRIENDS, () => []);
});

To note:

  • The last matcher for the user reducer is the main focus here.
  • The friends reducer now has two ways of modifying the state: “modifying” the state by pushing to it with .push, and returning a new empty state by directly returning [].

In my intuition this would work as it appears to be the same logic. However, this only works for the ADD_FRIEND action, and does nothing or emits an error about simultaneously modifying state and returning a new state for the REMOVE_ALL_FRIENDS action type.

This seems to be because the state being modified turns it to an ImmerJS Proxy object in the user reducer, but when it is passed to the friends reducer and it returns a state object directly instead of modifying it causing RTK to throw an error as it says you must only modify or return state, but not both. In the handler for ADD_FRIEND this is not an issue as it always modifies the state, the same as all the handlers in user.

As a hacky workaround I have manually checked whether the friends reducer returns a Proxy or a new state directly, and if it returns a new state then it sets it in the user reducer, but I am sure there is a better way:

import { createReducer, current } from "@reduxjs/toolkit";

const user = createReducer(initialState, (builder) => {
  builder
    .addMatcher((action) => action.type === ADD_FRIEND || action.type === REMOVE_ALL_FRIENDS),
    (state, action) => {
      const { time } = action.payload;

      state.lastActivity = time;

      const result = friends(state.friends, action);

      let output;
      
      // If state has been returned directly this will error and we can set the state manually,
      // Else this will not error because a Proxy has been returned, and thus the state has been
      // set already by the sub-reducer.
      try {
        output = current(result);
      } catch (error) {
        output = result;
      }

      if (output) {
        state.progress = output;
      }
    };
});

My question is then how can I fix this so that I don’t have to manually check the return type and can easily nest RTK reducers within each other, whether it be by restructuring my reducers or fixing the code logic?

Ideally I would still like to keep the friends reducer nested under the user reducer as that is how a lot of “vanilla” Redux code structures their state logic with many different reducers handling many different pieces of state, instead of them all being nested at the root-level with a single combineReducers call, but if there is a better and cleaner solution given I am fine with that too.

Thanks for any help, and sorry for the long post – just wanted to be as detailed as possible as other solutions online didn’t seem to address this exact problem.

Answer

The issue was that my original user reducer code was reducer was returning a new state object by spreading the state and setting the friends property in that object spread. This produced an error from ImmerJS as it was returning a new value from the user reducer and was also modifying it in the friends reducer at the same time.

My posted code worked (with some modifications thanks to Linda), but to fix my original code (and I had not posted the version that produced errors – apologies) I had to change the following:

.addMatcher(
  (action) =>
    action.type === ADD_FRIEND || action.type === REMOVE_ALL_FRIENDS,
  (state, action) => ({
    ...state,
    lastActivity: action.payload.time,
    friends: friends(state.friends, action)
  })
)

to:

.addMatcher(
  (action) =>
    action.type === ADD_FRIEND || action.type === REMOVE_ALL_FRIENDS,
  (state, action) => {
    const { time } = action.payload;
    state.lastActivity = time;
    state.friends = friends(state.friends, action);
  }
)

Thanks for the help, everyone.