Reputation: 409
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 createReducer
s. 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:
user
reducer is the main focus here.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.
Upvotes: 0
Views: 3311
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
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:
initialState
for the whole user as the initial state of friends
, instead of initialState.friends
or []
addMatcher
around the action.type
checkstate.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
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