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"/>