Matthew W.
Matthew W.

Reputation: 409

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:

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:

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.

Upvotes: 0

Views: 3311

Answers (3)

Matthew W.
Matthew W.

Reputation: 409

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.

Upvotes: 3

Linda Paiste
Linda Paiste

Reputation: 42228

In this particular case it's easy to handle the friends property in the user reducer: state.friends.push(newFriend) or state.friends = []. But there shouldn't be any issue with keeping it separate.

I did notice a few issues when trying to run your code:

  • Using the initialState for the whole user as the initial state of friends, instead of initialState.friends or []
  • Unmatched parentheses in addMatcher around the action.type check
  • Assigning to state.name instead of state.username

After fixing those I was not able to reproduce your issue. I am able to add and remove friends successfully.

Upvotes: 2

phry
phry

Reputation: 44186

This could actually be a bug in Redux Toolkit. Could you please file an issue with a reproduction CodeSandbox over at out github issue tracker?

Upvotes: 1

Related Questions