18

I'm working with a deeply nested state object in React. My code base dictates that we try to stick with function components and so every time I want to update a key/value pair inside that nested object, I have to use a hook to set the state. I can't seem to get at the deeper nested items, though. I have a drop down menu w/ an onChange handler. . .inside the onChange handler is an inline function to directly setValue of whatever key/val pair is changing.

The syntax I'm using after the spread operator in each inline function is wrong, however.

As a workaround, I have resorted to factoring out the inline function to its own function that rewrites the entire state object every time the state changes, but that is extremely time consuming and ugly. I'd rather do it inline like the below:

 const [stateObject, setStateObject] = useState({

    top_level_prop: [
      {
        nestedProp1: "nestVal1",
        nestedProp2: "nestVal2"
        nestedProp3: "nestVal3",
        nestedProp4: [
          {
            deepNestProp1: "deepNestedVal1",
            deepNestProp2: "deepNestedVal2"
          }
        ]
      }
    ]
  });

<h3>Top Level Prop</h3>

   <span>NestedProp1:</span>
     <select
       id="nested-prop1-selector"
       value={stateObject.top_level_prop[0].nestedProp1}
       onChange={e => setStateObject({...stateObject, 
       top_level_prop[0].nestedProp1: e.target.value})}
     >
      <option value="nestVal1">nestVal1</option>
      <option value="nestVal2">nestVal2</option>
      <option value="nestVal3">nestVal3</option>
     </select>

<h3>Nested Prop 4</h3>

   <span>Deep Nest Prop 1:</span>
     <select
       id="deep-nested-prop-1-selector"
       value={stateObject.top_level_prop[0].nestprop4[0].deepNestProp1}
       onChange={e => setStateObject({...stateObject, 
       top_level_prop[0].nestedProp4[0].deepNestProp1: e.target.value})}
     >
      <option value="deepNestVal1">deepNestVal1</option>
      <option value="deepNestVal2">deepNestVal2</option>
      <option value="deepNestVal3">deepNestVal3</option>
     </select>

The result of the code above gives me a "nestProp1" and "deepNestProp1" are undefined, presumably because they are never being reached/having their state changed by each selector. My expected output would be the selected option matching the value of whatever the selector's current val is (after the state changes). Any help would be greatly appreciated.

snejame
  • 602
  • 3
  • 9
  • 19
  • Create a minimal reproducible example so we can test the problem. https://stackoverflow.com/help/minimal-reproducible-example – Nabeel Mehmood Sep 05 '19 at 06:51

3 Answers3

20

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"/>
cbdeveloper
  • 27,898
  • 37
  • 155
  • 336
  • This works! Thank you. Now to work this into a dropdown. . . – snejame Sep 05 '19 at 13:53
  • Do you need that to be an inline function? It's easier to set it up as a standalone function and call it with the option value as parameter, isn't it? – cbdeveloper Sep 05 '19 at 13:57
  • Not necessarily. . .but I need to be able to run the inline function w/ an event instead of manually assigning it a string value like he has. Trying to figure that out now. . . – snejame Sep 05 '19 at 14:10
  • Do you know how to run the same function you referenced awhile also passing in an event from a dropdown? – snejame Sep 05 '19 at 14:17
  • I've added a new example. See if that helps. – cbdeveloper Sep 05 '19 at 14:31
  • See also this: https://stackoverflow.com/questions/57807849/react-html-select-element-onchange-function-trying-to-access-event-target-valu – cbdeveloper Sep 05 '19 at 15:10
  • Definitely useful. . .thank you! I've also found that `e.persist()` helps pass event.target.value into the example you provided so i don't have to hard code the desired new value: ``` const updateNestedState = e => { e.persist(); setNestedState(prevState => { prevState.top_level_prop[0].nestedProp4[0].deepNestProp1 = e.target.value; return { ...prevState }; }); }; ``` – snejame Sep 06 '19 at 07:01
  • This breaks the rules of React state. You have to copy each layer in the object hierarchy to get reliable renders. Just copying the top-level is not sufficient in all cases. – T.J. Crowder Sep 21 '21 at 16:09
5

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.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
3

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"/>
B.L.Coskey
  • 285
  • 2
  • 9