change value deep in redux state - reactjs

context
I'm rendering a form with a dynamic set of text elements. I've normalised my state using normalizr principles so there is an array of elementIds and an object containing the element properties referenced by the elementIds (see initial state in code sample below).
aim
My aim is simply for the two rendered elements to be editable. I'm successfully dispatching an action CHANGE_ELEMENT_VALUE to my store using an onChange callback, and action.id (referencing the id of the changed element) and action.value (the new value) are available in the reducer (see code below).
problem
My problem is that the text fields aren't changing when I type, even though I can see state changing using the devtools redux extension. My understanding is that react is not recognising a state change because the change is deep in state, and I'm not successfully creating a new state object, I'm probably referencing old instances somehow.
reducer code
Below is my bungled attempt to force a new state object. I'm assuming it's not working because my components are not being re-rendered. It also seems very inelegant.
let initialState = {
isLoading: false,
data: {
elementIds: ['name', 'email'],
elements: {
'name': {'id': 'name', 'value':'ben'},
'email': {'id':'email', 'value':'ben#test.com'},
},
},
error: false
}
function formReducer(state = initialState, action = null) {
switch(action.type) {
case types.CHANGE_ELEMENT_VALUE:
let newData = Object.assign(state.data)
newData.elements[action.id].value = action.value
return {...state, data: newData}
default:
return state;
}
}
export default formReducer;

you can make use of immutability-helper npm package and update your values in your reducer
import update from 'immutability-helper';
let initialState = {
isLoading: false,
data: {
elementIds: ['name', 'email'],
elements: {
'name': {'id': 'name', 'value':'ben'},
'email': {'id':'email', 'value':'ben#test.com'},
},
},
error: false
}
function formReducer(state = initialState, action = null) {
switch(action.type) {
case types.CHANGE_ELEMENT_VALUE:
return update(state, {
data : {
elements: {
[action.id]: {
value: {
$set: 'new value'
}
}
}
}
})
default:
return state;
}
}
export default formReducer;
update() provides simple syntactic sugar around this pattern to make
writing this code easier. While the syntax takes a little getting used
to (though it's inspired by MongoDB's query language) there's no
redundancy, it's statically analyzable and it's not much more typing
than the mutative version.

Object.assign only operates one level deep; i.e. it does not recursively clone the entire object tree. So, your top-level object is cloned but it doesn't trigger a rerender since your reducer mutates a value deep in the cloned object.
I would recommend looking into the deep-extend package and updating your state as follows:
import extend from 'deep-extend';
...
return extend(state, {
elements: {
[key]: value
}
});

Related

Is mutating "copy" of initial state inside a passed reducer function in "Redux Toolkits" createSlice() a bad practice?

Context
I'm trying to create the state for a multi-level accordion menu, where the top-level items are called subjects, each subject has multiple chapters and each chapter will have multiple articles.
At any given time only a single "Subject" can be in the "selected" state. The same applies to chapters and articles, with the additional restriction being they need to be "Children" of a parent in the "selected" state.
Code
I have a deeply nested object that is to be passed as the initial state to the createSlice() method,it has the following shape,
const initialState = {
currentArticle: null,
currentChapter: null,
currentSubject: null
subjects: [
{
id:"001",
chapters: [
{
id: "001001",
articles: [
{
id: "001001001",
selected: false
},
//....... more articles
],
selected: false
},
//....... more chapters
],
selected: false
},
//....... more subjects
]
}
Following is my createSlice() method,
export const articleNavigationSlice = createSlice({
name: "articlenav",
initialState: initialState,
reducers: {
setTopic: (state, { payload }) => {
const newNavigationState = initialState.subjects.map((subject) => {
if (payload.id === subject.id) {
subject.selected = true;
state.currentSubject = subject.id
}
return subject;
});
state.subjects = newNavigationState;
},
// ...... more reducer functions
},
});
The subjects array is directly used for rendering the UI, Every time a dispatch function is called I conditionally use the initial state and then calculate the next state,(those conditions are not included in the following code snippets for simplicities sake), For now let's consider that I use the initial states "subject" array every single time I need to calculate the next state instead of using the previous state passed to the reducer.
The reason for using the initial state is to not have to manually set the selected state of nested objects to false, in case the parents selected state changes.
Problem
However when I dispatch an action that executes the "setTopic" reducer function I get the following error,
TypeError: Cannot assign to read only property 'selected' of object '#<Object>'
Attempts to solve the issue
Using the spread operator to create a new copy of initialState within the reducer.
const copyInitialState = { ...initialState }
const newNavigationState = copyInitialState.subjects.map((subject) => {
//............
Using Object.assign() to create a new Object, within the reducer
const copyInitialState = {};
Object.assign(copyInitialState, initialState);
const newNavigationState = copyInitialState.subjects.map((subject) => {
//............
create 2 copies of the initial state, before invoking createSlice and pass one copy inside the createSlice() invocation as the initial state and use the other copy within the passed reducer function.
const initialStateCopy = Object.assign(initialState);
const initializedInitialState = Object.assign(initialState);
export const articleNavigationSlice = createSlice({
name: "articlenav",
initialState: initializedInitialState,
reducers: {
setTopic: (state, { payload }) => {
const newNavigationState = initialStateCopy.subjects.map((subject) => {
//............
I.E : I tried this approach with the spread operator as well.
The only solution that works(not a desirable approach )
explicitly declare a completely new constant and initialize it in the exact same way as the initialState object. In this case, this simply means I'm copying the exact same object creation code one after the other so that they are completely two different objects,
const initialState = {//.... deeply nested object}
const initialStateExplicitCopy = {//.... deeply nested object}
export const articleNavigationSlice = createSlice({
name: "articlenav",
initialState: initialState,
reducers: {
setTopic: (state, { payload }) => {
const newNavigationState = initialStateExplicitCopy.subjects.map((subject) => {
Question
I think this has to do something with Immer and how it treats the initial state Object. I see that even though I do an Object.assign() nested objects are sealed and frozen.
Does this mean I'm attempting to perform something wrong? or something which is considered bad practice? Does this in any way make the reducer impure? If so I don't see why because the initial state never changes, I'm just using the initial state all the time to calculate the next state.
Is there a better approach to handle this while using the redux toolkit?
Yeah, the problem is the attempt to mutate initialState, for a couple different reasons.
It's only safe to write code that "mutates" data if that data has actually been passed through Immer and wrapped in proxies, so that Immer can track the attempted changes. When you reference initialState, that object hasn't been handed to Immer yet, so your code really is trying to mutate initialState.
Fortunately, when you call createSlice({initialState: someInitialStateValue}), createSlice internally "freezes" that value to make sure you don't accidentally mutate it for real later on. That's why you're getting the error - it's telling you you are doing something wrong.
Conceptually, I'm not sure why you're trying to always base the calculations off of initialState. Wouldn't you want to be doing updates based on the current state as the starting point?
If you truly do need to use initialState as the starting point, the best option is to use Immer directly and feed it initialState. Immer's main function is exported from RTK as createNextState, so you can use that to wrap your current logic
import { createNextState } from "#reduxjs/toolkit";
export const articleNavigationSlice = createSlice({
name: "articlenav",
initialState: initialState,
reducers: {
setTopic: (state, { payload }) => {
const newNavigationState = createNextState(initialState.subjects, draftSubjects) => {
const subject = draftSubjects.find(subject => subject.id === payload.id);
if (subject) {
subject.selected = true;
state.currentSubject = subject.id
}
}
state.subjects = newNavigationState;
},
}
});

Tell React/Redux to ignore object references and just do a deep equality check

Problem - my component is updating even if the input data hasn't changed and I suspect it's because an object ref change while a deep compare didn't change.
I have a reducer like so:
const getDefaultState = () => ({
mainNotifMessage: '(unknown message)',
notifDetails: '(unknown notification details)',
snackBarOpen: true
});
export type CPAction = {type: string, val: object}
export const notifsReducer = (state = getDefaultState(), action: CPAction) => {
switch (action.type) {
case c.NOTIFY_ERROR:
return {
...state,
...action.val
};
default:
return {
...state
};
}
};
and then in an component I have:
function mapStateToProps(state: RootState) {
return {
notifs: state.notifs
};
}
I think what happens is that state.notif always gets a new object reference, so React/Redux is always updating my component even if there are no changes. But if I change it to this:
function mapStateToProps(state: RootState) {
return {
mainNotifMessage: state.notifs.mainNotifMessage,
snackBarOpen: state.notifs.snackBarOpen,
severity: state.notifs.severity,
notifDetails: state.notifs.notifDetails
};
}
then it doesn't update the components. Is there a way to tell Redux/React to always to do a deep compare and not return early on object reference differences?
I think what happens is that state.notif always gets a new object
reference, so React/Redux is always updating my component even if
there are no changes.
By default, React-Redux decides whether the contents of the object returned from mapStateToProps are different using === comparison (a "shallow equality" check) on each fields of the returned object.
default:
return {
...state
};
You are returning a new reference each time in your default state.
So when you read values using dot notation, you get the same values.
But for Javascript, it is a new object reference, hence React-Redux will try to re-render the component.
You can just do return state instead of spreading it and creating a new object reference in the default case of your reducer.
Reference : https://react-redux.js.org/using-react-redux/connect-mapstate#return-values-determine-if-your-component-re-renders

How can you replace entire state in Redux Toolkit reducer?

EDIT: The solution is to return state after I replace it completely (return state = {...action.payload})! But why? I don't need to return it when I replace the fields individually.
I'm working with the Redux Toolkit, which simplifies some Redux boilerplate. One thing they do is use Immer to allow you to directly 'modify' state (in fact, you're not). It works fine, except I don't know how to replace my section of state entirely. For instance, I want to do something like this
const reducer = createReducer({ s: '', blip: [] }, {
[postsBogus.type]: (state, action) => {
state = { ...action.payload };
}
but state remains untouched. Instead I have to do this
[postsBogus.type]: (state, action) => {
state.s = action.payload.s;
state.blip = action.payload.blip;
}
Is there a way I can replace state entirely?
Yes, as you noted, you must return a new value to replace the state entirely.
Even in a "plain" Redux reducer, assigning state = newValue does nothing, because all that does is say that the local function variable named state is now pointing to a different value in memory. That does nothing to return a new value.
For Immer specifically, you can either mutate the contents of the Proxy-wrapped state value as long as it's an object or array, or you can return an entirely new value, but not both at once.
You can, but not in this way, when you say:
function x(y){
y = 4
}
You're mutating the function parameter, but not the state of redux,
you have two options to update this state of your redux store:
either to set state.your_state_name = something or, in your case, what you want to do is to return a new object, the new object is what the new state value is going to be.
simple example:
myReducerFunc: (state, action) => {
return {...action.payload }
},
another example:
const loggedInUserSlice = createSlice({
name: '$loggedInUser',
initialState: {
isLoggedIn: false,
},
reducers: {
loggedIn: (state, action) => {
return {
isLoggedIn: true,
...action.payload,
}
},
},
})

react redux reducer adding object to array in object

I am working on a react redux app. I have state object like
const initialState = {
articles: []
};
each item in 'articles' is like
{
id:1,
data:'data'
}
I need to add another object to the array without removing the existing objects in the array.
I tried
return {...state,articles: [...state.articles] };
in my reducer but it ends up removing the existing items.
what should it be so that it does not remove existing items?
In Reducer return using immutable update pattern
return { ...state, articles: action.payload.data };
for more information on reducer immutable pattern.
You should spread your current articles into a new array as you did, and append the payload from your action.
return { ...state, articles: [...state.articles, payload] }
An example of how your action should look:
export function addArticle(article) {
return { type: "ADD_ARTICLE", payload: { article } };
}

Changing state in Redux

I am trying to add an element to an array in the state and change another array element's property. Say we have the following state structure:
{
menuItems: [{
href: '/',
active: true
}]
}
After dispatching the ADD_MENU_ITEM action I want to end up with this state:
{
menuItems: [{
href: '/new',
active: true
}, {
href: '/',
active: false,
}]
}
I have tried managing this in Redux reducers in several fashions:
function reducer(state = {}, action) {
switch (action.type) {
case ADD_MENU_ITEM: {
let menuItems = state.menuItems;
let newMenuItem = action.newMenuItem;
// First try
menuItems[0].active = false;
menuItems.unshift(newMenuItem);
state = Object.assign({}, state, { menuItems: menuItems });
// Second try
menuItems[0].active = false;
menuItems.unshift(newMenuItem);
state = Object.assign({}, state, {menuItems: Object.assign([], menuItems)});
// Third try
menuItems[0].active = false;
state = (Object.assign({}, state, {
menuItems: [
Object.assign({}, newMenuItem),
...menuItems
]
}));
// Fourth try
menuItems[0].active = false;
state = update(state, {
menuItems: {$unshift: new Array(newMenuItem)}
});
console.log(state);
return state;
}
}
}
In the fourth try, I am using React's Immutability Helpers but it never works. I logged the state to the console before returning the state and it logs correctly, but when logging inside the components which get re-rendered, the menuItems array does not add the first item, although the active member is set to false.
What could I be doing wrong?
The state in the reducer should be immutable and for this reason should not be modified. It is also recommended to flatten your object whenever possible.
In your scenario your initial state could be an array as such:
[{
href: '/',
active: true
}]
In your reducer, try returning a brand new array as follows:
function reducer(state = {}, action) {
switch (action.type) {
case ADD_MENU_ITEM: {
return [
action.newMenuItem,
...state.map(item => Object.assign({}, item, { active: false }))
];
}
}
}
More information about reducers can be found here: Redux Reducers Documentation
Helpful excerpt from the documentation:
It’s very important that the reducer stays pure. Things you should never do inside a reducer:
Mutate its arguments;
Perform side effects like API calls and routing transitions;
Calling non-pure functions, e.g. Date.now() or Math.random().
MORE INFO ADDED
In your reducer, and for all four tries, you are modifying the existing state before returning it.
This results in react-redux, when checking if your state has changed, not to see any changes as both the previous and next states are pointing to the same object.
Here are the lines I am referring to:
First Try:
// This line modifies the existing state.
state = Object.assign({}, state, { menuItems: menuItems });
Second Try:
// This line modifies the existing state.
state = Object.assign({}, state, {menuItems: Object.assign([], menuItems)});
Third Try:
// This line modifies the existing state.
state = (Object.assign({}, state, {
menuItems: [
Object.assign({}, newMenuItem),
...menuItems
]
}));
Fourth Try:
// This line modifies the existing state.
state = update(state, {
menuItems: {$unshift: new Array(newMenuItem)}
});

Resources