React: Setting State for Deeply Nested Objects w/ Hooks
Another approach is to use the useReducer hook
const App = () => {
const reducer = (state, action) =>{
return {...state, [action.type]: action.payload}
}
const [state, dispatch] = React.useReducer(reducer,{
propA: 'foo1',
propB: 'bar1'
});
const changeSelect = (prop, event) => {
const newValue = event.target.value;
dispatch({type: prop, payload: newValue});
}
return(
<React.Fragment>
<div>My nested state:</div>
<div>{JSON.stringify(state)}</div>
<select
value={state.propA}
onChange={(e) => changeSelect('propA', e)}
>
<option value='foo1'>foo1</option>
<option value='foo2'>foo2</option>
<option value='foo3'>foo3</option>
</select>
<select
value={state.propB}
onChange={(e) => changeSelect('propB', e)}
>
<option value='bar1'>bar1</option>
<option value='bar2'>bar2</option>
<option value='bar3'>bar3</option>
</select>
</React.Fragment>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"/>
The primary rule of React state is do not modify state directly. That includes objects held within the top-level state object, or objects held within them, etc. So to modify your nested object and have React work reliably with the result, you must copy each layer that you change. (Yes, really. Details below, with documentation links.)
Separately, when you're updating state based on existing state, you're best off using the callback version of the state setter, because state updates may be asynchronous (I don't know why they say "may be" there, they are asynchronous) and state updates are merged, so using the old state object can result in stale information being put back in state.
With that in mind, let's look at your second change handler (since it goes deeper than the first one), which needs to update stateObject.top_level_prop[0].nestprop4[0].deepNestProp1
. To do that properly, we have to copy the deepest object we're modifying (stateObject.top_level_prop[0].nestprop4[0]
) and all of its parent objects; other objects can be reused. So that's:
stateObject
top_level_prop
top_level_prop[0]
top_level_prop[0].nestprop4
top_level_prop[0].nestprop4[0]
That's because they're all "changed" by changing top_level_prop[0].nestprop4[0].deepNestProp1
.
So:
onChange={({target: {value}}) => {
// Update `stateObject.top_level_prop[0].nestprop4[0].deepNestProp1`:
setStateObject(prev => {
// Copy of `stateObject` and `stateObject.top_level_prop`
const update = {
...prev,
top_level_prop: prev.top_level_prop.slice(), // Or `[...prev.top_level_prop]`
};
// Copy of `stateObject.top_level_prop[0]` and `stateObject.top_level_prop[0].nextprop4`
update.top_level_prop[0] = {
...update.top_level_prop[0],
nextprop4: update.top_level_prop[0].nextprop4.slice()
};
// Copy of `stateObject.top_level_prop[0].nextprop4[0]`, setting the new value on the copy
update.top_level_prop[0].nextprop4[0] = {
...update.top_level_prop[0].nextprop4[0],
deepNestProp1: value
};
return update;
});
}}
It's fine not to copy the other objects in the tree that aren't changing because any component rendering them doesn't need re-rendering, but the deepest object that we're changing and all of its parent objects need to be copied.
The awkwardness around that is one reason for keeping state objects used with useState
small when possible.
But do we really have to do that?
Yes, let's look at an example. Here's some code that doesn't do the necessary copies:
const {useState} = React;
const ShowNamed = React.memo(
({obj}) => <div>name: {obj.name}</div>
);
const Example = () => {
const [outer, setOuter] = useState({
name: "outer",
middle: {
name: "middle",
inner: {
name: "inner",
},
},
});
const change = () => {
setOuter(prev => {
console.log("Changed");
prev.middle.inner.name = prev.middle.inner.name.toLocaleUpperCase();
return {...prev};
});
};
return <div>
<ShowNamed obj={outer} />
<ShowNamed obj={outer.middle} />
<ShowNamed obj={outer.middle.inner} />
<input type="button" value="Change" onClick={change} />
</div>;
};
ReactDOM.render(<Example />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
Notice how clicking the button doesn't seem to do anything (other than logging "Changed"), even though the state was changed. That's because the object passed to ShowName
didn't change, so ShowName
didn't re-render.
Here's one that does the necessary updates:
const {useState} = React;
const ShowNamed = React.memo(
({obj}) => <div>name: {obj.name}</div>
);
const Example = () => {
const [outer, setOuter] = useState({
name: "outer",
middle: {
name: "middle",
inner: {
name: "inner",
},
},
});
const change = () => {
setOuter(prev => {
console.log("Changed");
const update = {
...prev,
middle: {
...prev.middle,
inner: {
...prev.middle.inner,
name: prev.middle.inner.name.toLocaleUpperCase()
},
},
};
return update;
});
};
return <div>
<ShowNamed obj={outer} />
<ShowNamed obj={outer.middle} />
<ShowNamed obj={outer.middle.inner} />
<input type="button" value="Change" onClick={change} />
</div>;
};
ReactDOM.render(<Example />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
That example uses React.memo
to avoid re-rendering child components when their props haven't changed. The same thing happens with PureComponent
or any component that implements shouldComponentUpdate
and doesn't update when its props haven't changed.
React.memo
/ PureComponent
/ shouldComponentUpdate
are used in major codebases (and polished components) to avoid unnecessary re-rendering. Naïve incomplete state updates will bite you when using them, and possibly at other times as well.
I think you should be using the functional form of setState
, so you can have access to the current state and update it.
Like:
setState((prevState) =>
//DO WHATEVER WITH THE CURRENT STATE AND RETURN A NEW ONE
return newState;
);
See if that helps:
function App() {
const [nestedState,setNestedState] = React.useState({
top_level_prop: [
{
nestedProp1: "nestVal1",
nestedProp2: "nestVal2",
nestedProp3: "nestVal3",
nestedProp4: [
{
deepNestProp1: "deepNestedVal1",
deepNestProp2: "deepNestedVal2"
}
]
}
]
});
return(
<React.Fragment>
<div>This is my nestedState:</div>
<div>{JSON.stringify(nestedState)}</div>
<button
onClick={() => setNestedState((prevState) => {
prevState.top_level_prop[0].nestedProp4[0].deepNestProp1 = 'XXX';
return({
...prevState
})
}
)}
>
Click to change nestedProp4[0].deepNestProp1
</button>
</React.Fragment>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"/>
UPDATE: With dropdown
function App() {
const [nestedState,setNestedState] = React.useState({
propA: 'foo1',
propB: 'bar'
});
function changeSelect(event) {
const newValue = event.target.value;
setNestedState((prevState) => {
return({
...prevState,
propA: newValue
});
});
}
return(
<React.Fragment>
<div>My nested state:</div>
<div>{JSON.stringify(nestedState)}</div>
<select
value={nestedState.propA}
onChange={changeSelect}
>
<option value='foo1'>foo1</option>
<option value='foo2'>foo2</option>
<option value='foo3'>foo3</option>
</select>
</React.Fragment>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"/>