Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React: Setting State for Deeply Nested Objects w/ Hooks

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.

like image 424
snejame Avatar asked Sep 05 '19 05:09

snejame


3 Answers

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"/>
like image 145
cbdeveloper Avatar answered Nov 18 '22 17:11

cbdeveloper


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"/>
like image 3
B.L.Coskey Avatar answered Nov 18 '22 17:11

B.L.Coskey


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.

like image 5
T.J. Crowder Avatar answered Nov 18 '22 17:11

T.J. Crowder