1

I'm experiencing some odd behavior with react's useState hook. I would like to know why this is happening. I can see a few ways to sidestep this behavior, but want to know whats going on.

I am initializing the state with the following const:

const initialValues = {
  order_id: '',
  postal_code: '',
  products: [
    {
      number: '',
      qty: ''
    }
  ]
}

const App = (props) => {
  const [values, setValues] = React.useState(initialValues);
...

products is an array of variable size. As the user fills in fields more appear. The change handler is:

  const handleProductChange = (key) => (field) => (e) => {
    if (e.target.value >= 0 || e.target.value == '') {
      let products = values.products;
      products[key][field] = e.target.value;
      setValues({ ...values, products });
    }
  }

What I am noticing is that if I console log initialValues, the products change when the fields are changed. None of the other fields change, only inside the array.

Here is a codepen of a working example.

How is this possible? If you look at the full codepen, you'll see that initialValues is only referenced when setting default state, and resetting it. So I don't understand why it would be trying to update that variable at all. In addition, its a const declared outside of the component, so shouldn't that not work anyway?

I attempted the following with the same result:

const initialProducts = [
  {
    number: '',
    qty: ''
  }
];

const initialValues = {
  order_id: '',
  postal_code: '',
  products: initialProducts
}

In this case, both consts were modified.

Any insight would be appreciated.

Brian Thompson
  • 13,263
  • 4
  • 23
  • 43
  • use multiple `useState` and avoid using nested object and array inside them which is resulting in your issue. Its because you are trying to modify things and then spread out `...values` but that spread contains pointer to the OLD ref which happens to be initial value. By doing `products.push({number: '', qty: ''});` you are modifying Initial value – Rikin Oct 10 '19 at 17:15
  • 1
    `products[key][field] = e.target.value` is the problem. You're modifying the properties within the `const` reference, rather than creating a new reference to replace the initial value. – Patrick Roberts Oct 10 '19 at 17:23
  • @PatrickRoberts I see that that is whats happening, but why does `let products = values.products;` not fix that? Shouldn't that make it so that only the new variable is being modified? – Brian Thompson Oct 10 '19 at 18:21
  • 1
    @BrianThompson variables are just references. An assignment doesn't copy an object, it just causes the left-hand side to reference the evaluated expression on the right hand side. – Patrick Roberts Oct 10 '19 at 18:24
  • I see, thank you for the explanation – Brian Thompson Oct 10 '19 at 18:35
  • 1
    Also [`const`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) is not to be confused with [`Object.freeze()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze). `const foo = { key: 'value' };` prevents `foo = { key: 'new value' };` but it doesn't prevent `foo.key = 'new value';`, whereas `let foo = Object.freeze({ key: 'value' });` prevents the latter but not the former. – Patrick Roberts Oct 10 '19 at 18:40

3 Answers3

1

Alongside exploding state into multiple of 1 level deep you may inline your initial:

  = useState({ ... });

or wrap it into function

function getInitial() {
  return {
  ....
  };
}

// ...

 = useState(getInitial());

Both approaches will give you brand new object on each call so you will be safe.

Anyway you are responsible to decide if you need 2+ level nested state. Say I see it legit to have someone's information to be object with address been object as well(2nd level deep). Splitting state into targetPersonAddress, sourePersonAddress and whoEverElsePersonAddress just to avoid nesting looks like affecting readability to me.

skyboyer
  • 22,209
  • 7
  • 57
  • 64
  • I would agree with the comment on readability. And I like the idea of making initial values a function that returns the object, and I may still use that. However, this will only stop the `initialValues` from being modified, the state object's nested values are still mutable, and were being mutated the way it was written. I didn't want to inline the initial state to make it easy and clear when resetting the form to just set values back to initial. – Brian Thompson Oct 10 '19 at 19:16
  • 1
    yeah, it addresses only part of issue you've run into, I saw rest was discussed in comments in details. – skyboyer Oct 10 '19 at 20:03
1

This would be a good candidate for a custom hook. Let's call it usePureState() and allow it to be used the same as useState() except the dispatcher can accept nested objects which will immutably update the state. To implement it, we'll use useReducer() instead of useState():

const pureReduce = (oldState, newState) => (
  oldState instanceof Object
    ? Object.assign(
        Array.isArray(oldState) ? [...oldState] : { ...oldState },
        ...Object.keys(newState).map(
          key => ({ [key]: pureReduce(oldState[key], newState[key]) })
        )
      )
    : newState
);

const usePureState = initialState => (
  React.useReducer(pureReduce, initialState)
);

Then the usage would be:

const [values, setValues] = usePureState(initialValues);
...
const handleProductChange = key => field => event => {
  if (event.target.value >= 0 || event.target.value === '') {
    setValues({
      products: { [key]: { [field]: event.target.value } }
    });
  }
};
Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
0

Probably the simplest move forward is to create a new useState for products which I had started to suspect before asking the question, but a solution to keep the logic similar to how it was before would be:

let products = values.products.map(product => ({...product}));

to create a completely new array as well as new nested objects.

As @PatrickRoberts pointed out, the products variable was not correctly creating a new array, but was continuing to point to the array reference in state, which is why it was being modified.

More explanation on the underlying reason initialValues was changed: Is JavaScript a pass-by-reference or pass-by-value language?

Brian Thompson
  • 13,263
  • 4
  • 23
  • 43