0

EDIT: Codepen is here https://codepen.io/mark-kelly-the-looper/pen/abGBwzv

I have a component that display "channels" and allows their minimum and maximum to be changed. An example of a channels array is:

[
    {
        "minimum": 0,
        "maximum": 262144,
        "name": "FSC-A",
        "defaultScale": "lin"
    },
    {
        "minimum": 0,
        "maximum": 262144,
        "name": "SSC-A",
        "defaultScale": "lin"
    },
    {
        "minimum": -27.719999313354492,
        "maximum": 262144,
        "name": "Comp-FITC-A - CTRL",
        "defaultScale": "bi"
    },
    {
        "minimum": -73.08000183105469,
        "maximum": 262144,
        "name": "Comp-PE-A - CTRL",
        "defaultScale": "bi"
    },
    {
        "minimum": -38.939998626708984,
        "maximum": 262144,
        "name": "Comp-APC-A - CD45",
        "defaultScale": "bi"
    },
    {
        "minimum": 0,
        "maximum": 262144,
        "name": "Time",
        "defaultScale": "lin"
    }
]

In my component, I send in the channels array as props, copy it to a channels object which uses useState, render the channels data, and allow a user to change a value with an input. As they type, I think call updateRanges and simply update the channels. I then expect this new value to be rendered in the input. Bizarrely, it is not.

I included first_name and last_name as a test to see if Im doing something fundamentally wrong. But no, as I type into first_name and last_name, the new value is rendered in the UI.

enter image description here

My component is:

  const [channels, setChannels] = useState([]);

  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");


  useEffect(() => {
    console.log("in Use Effect, setting the channels");
    setChannels(JSON.parse(JSON.stringify(props.channels)));
  }, [props.channels]);



   const updateRanges = (rowI, minOrMax, newRange) => {
      console.log("rowI, minOrMax, newRange is ", rowI, minOrMax, newRange);

      console.log("setting the channels...");
      channels[rowI]["minimum"] = parseFloat(newRange);
      setChannels(channels);
  };



  return (
    <div>
      {channels?.map((rowData, rowI) => {
        console.log(">>>looping through channels ");
        return (
          <div>
            <input
              style={{
                //width: "20%",
                outline: "none",
                border: "none",
              }}
              value={rowData.minimum}
              onChange={(newColumnData) => {
                console.log("in the onChange ");
                updateRanges(rowI, "minimum", newColumnData.target.value);
              }}
            />
          </div>
        );
      })}

      <input
        id="first_name"
        name="first_name"
        type="text"
        onChange={(event) => setFirstName(event.target.value)}
        value={firstName}
      />
      <input
        id="last_name"
        name="last_name"
        type="text"
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
      />
    </div>
    )

As I type into the minimum input (I typed 9 in the field with 0), the console logs:

in the onChange
rowI, minOrMax, newRange is  1 minimum 09
setting the channels...

I do NOT see >>>looping through channels

What could possibly be going on?

Mark
  • 4,428
  • 14
  • 60
  • 116

3 Answers3

3

One of the fundamental rules of React is that you shouldn't update your state directly. You should treat your state as if it was read-only. If you do modify it, then React isn't able to detect that your state has changed and rerender your component/UI correctly.

Why your UI isn't updating

Below is the main issue that you're facing with your code:

channels[rowI][minOrMax] = parseFloat(newRange);
setChannels(channels);

Here you are modifying your channels state directly and then passing that modified state to setChannels(). Under the hood, React will check if it needs to rerender your component by comparing whether the new state you passed into your setChannels() is different from the current channels state (using Object.is() to compare the two). Because you modified your channels state directly and are passing through your current state array reference to the setChannels() hook, React will see that your current state and the value you passed to the hook are in fact the same, and thus won't rerender.

How to make your UI update

To make your UI update, you first need to create a new array that has its own reference that is unique and different from your current channels state reference. Secondly, the inner object that you're updating also needs to be a new object containing the item you want updated. Below are a few methods to achieve these two steps to update your UI:

Use Array.prototype.with():

One of JavaScript's latest and greatest methods (ES2023) is the .with() method on arrays. It behaves in a similar way to updating an array index with array[i] = value, however, it doesn't modify array and instead returns a new array with value at the index specified. This takes care of creating the new array reference for us and putting the new value in the correct location. Then, to avoid mutating the inner object reference, we can use the spread syntax to copy and update the object you want to update:

setChannels(channels => 
  channels.with(rowI, {...channels[rowI], [minOrMax]: parseFloat(newRange)})
);

Use Array.prototype.map():

Another option to correctly update your state is to use .map() (which is suggested by the React documentation). Within the .map() method, you can access the current item index to decide whether you should replace the current object, or keep the current object as is. For the object you want to replace, you can replace it with a new instance of your object that has its minOrMax property updated like so:

setChannels(channels => channels.map((channel, i) => 
  i === rowI 
    ? {...channel, [minOrMax]: parseFloat(newRange)} // create a new object with "minumum" set to newRange if i === rowI
    : channel // if i !== rowI then don't update the currennt item at this index
));

Shallow copy and update:

Alternatively, a similar result can be achieved by firstly (shallow) copying your array (using the spread syntax or rest properties), and then updating your desired index with a new object:

setChannels(([...channels]) => {  // shallow copy current state so `channels` is a new array reference (done in the parameters via destructuring)
  channels[rowI] = {...channels[rowI], [minOrMax]: parseFloat(newRange)};
  return channels; // return the updated state
});

Note that in all examples above, the .with(), .map(), and the ([...channels]) => create/return new arrays that are completely different references in memory to the original channels state. This means that when we return it from the state updater function passed to setChannels(), React will see that this new state is unique and different. It is also important that we create new references for the data inside of the array when we want to change them, otherwise, we can still encounter scenarios where our UI doesn't update (see examples here and here of that happening). That is why we're creating a new object with the spread syntax ... rather than directly modifying the object with:

channel[minOrMax] = parseFloat(newRange); 

Deep clone (not recommended):

You will also often see a variation of the above code that does something along the lines of:

const channelsCopy = JSON.parse(JSON.stringify(channels));
channelsCopy[rowI][minOrMax] = parseFloat(newRange);
setChannels(channelsCopy);

or potentially a variation using structuredClone instead of .parse() and .stringify(). While these do work, it clones your entire state, meaning that objects that didn't need replacing or updating will now be replaced regardless of whether they changed or not, potentially leading to performance issues with your UI.

Alternative option with useImmer():

As you can see, the above isn't nearly as readable compared to what you originally had. Luckily there is a package called Immer that makes it easier for you to write immutable code (like above) in a way that is much more readable and similar to what you originally wrote. With the use-immer package, we get access to a hook that allows us to easily update our state:

import { useImmer } from 'use-immer';
// ...

const [channels, updateChannels] = useImmer([]);
// ...

const updateRanges = (rowI, minOrMax, newRange) => {
  updateChannels(draft => { // you can mutate draft as you wish :)
    draft[rowI][minOrMax] = parseFloat(newRange); // this is fine
  });
}

Component improvements

The above code changes should resolve your UI update issue. However, you still have some code improvements that you should consider. The first is that every item that you create with .map() should have a unique key prop that's associated with the item from the array you're currently mapping (read here as to why this is important). From your channels array, the key could be the name key (it's up to you to decide if that is a suitable key by determining if it's always unique for each array element and never changes). Otherwise, you might add an id property to your objects within your original channels array:

[ { "id": 1, "minimum": 0, "maximum": 262144, "name": "FSC-A", "defaultScale": "lin" }, { "id": 2, ...}, ...]

Then when you map your objects, add a key prop to the outermost element that you're creating:

...
channels.map((rowData, rowI) => { // no need for `channels?.` as `channels` state always holds an array in your example
  return (<div key={rowData.id}>...</div>); // add the key prop
});
...

Depending on your exact case, your useEffect() might be unnecessary. In your example, it currently causes two renders to occur each time props.channels changes. Instead of using useEffect(), you can directly assign your component's props to your state, for example:

const [channels, setChannels] = useState(props.channels);

When you assign it, you don't need to deep clone with .stringify() and .parse() like you were doing within your useEffect(). We never mutate channels directly, so there is no need to deep clone. This does mean however that channels won't update when your props.channels changes. In your example that can't happen because you're passing through a hardcoded array to MyComponent, but if in your real code you're passing channels based on another state value as a prop to MyComponent then you might face this issue. In that case, you should reconsider whether you need the channels state in your MyComponent and whether you can use the channels state and its state updater function from the parent component (see here for more details).


Working example

See working example below:

const MyComponent = (props) => {
  const [loading, setLoading] = React.useState(true);
  const [channels, setChannels] = React.useState(props.channels);
  
  const updateRanges = (rowI, minOrMax, newRange) => {
    setChannels(([...c]) => { // copy channels state (and store that copy in a local variable called `c`)
      c[rowI] = {...c[rowI], [minOrMax]: parseFloat(newRange || 0)}; // update the copied state to point to a new object with the updated value for "minimum"
      return c; // return the updated state array for React to use for the next render. 
    });
  };
  
  return (
    <div>
      {channels.map((rowData, rowI) => {
        return (
          <div key={rowData.id}> {/* <--- Add a key prop (consider removing this div if it only has one child and isn't needed for stylying) */}
            <input
              value={rowData.minimum}
              onChange={(newColumnData) => {
                updateRanges(rowI, "minimum", newColumnData.target.value);
              }}
            />
          </div>
        );
      })}
    </div>
  );
}

const channels = [{"id": 1, "minimum":0,"maximum":262144,"name":"FSC-A","defaultScale":"lin"},{"id": 2, "minimum":0,"maximum":262144,"name":"SSC-A","defaultScale":"lin"}];
ReactDOM.render(<MyComponent channels={channels} />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<div id="root"></div>
Nick Parsons
  • 45,728
  • 6
  • 46
  • 64
1

I just figured it out. Inside your updateRanges instead of

//comment this
//setChannels(channels);

Use this

// Replace with
setChannels([...channels]);

Final function

 const updateRanges = (rowI, minOrMax, newRange) => {
    console.log("rowI, minOrMax, newRange is ", rowI, minOrMax, newRange);
    channels[rowI][minOrMax] = parseFloat(newRange);
    //comment this
    //setChannels(channels);
    // Replace with
    setChannels([...channels]);
  }; 

Problem Reason

React won't update the DOM every time you give the same state to a setState. For primitive values like Number, String, and Boolean, it's obvious to know wether we are giving a different value or not.

For referenced values like Object and Array in the other hand, changing their content doesn't flag them as different. It should be a different memory reference

React, component not re-rendering after change in an array state

  • setChannels(channels); will just update array reference in memory setChannels([...channels]); will change array values(state) and hence once state is changed react will rerender. – Mursleen Amjad Sep 16 '22 at 15:23
1
   const updateRanges = (rowI, minOrMax, newRange) => {
      console.log("rowI, minOrMax, newRange is ", rowI, minOrMax, newRange);

      console.log("setting the channels...");
      channels[rowI]["minimum"] = parseFloat(newRange); // <-- MUTATING THE STATE!
      setChannels(channels);
  };

As you can see you are mutating the state. Once you mutate the state then you call setChannels(channels). The solution is to create copies of the data when you need to modify them.


Now think about following example,

const [age, setAge] = useState(30);

const someEventHandler = () => setAge(30);

By assuming we never changed age to something else than initial value 30, we call someEventHandler(). You can see that setAge is going to call with value 30 which is the same value currently holding in the state.

So in order to improve performance React will check if setState is call with the current state value. If that the case, then React won't re render you component.

Since the React functional components are(should) pure, then setting the same state should output the same result. So why re render again ?


Same thing happened to your code, You are directly update the channels variable without using setState.

channels[rowI]["minimum"] = parseFloat(newRange);

So now when you actually call setChannel React sees that both current and the value you are going to set are same. So React will not do anything.

A simple way to fix this is use the same trick you used to initialised your channels state when prop change.

That is,

   const updateRanges = (rowI, minOrMax, newRange) => {
      const deepCpy = JSON.parse(JSON.stringify(channels))
      deepCpy[rowI].minimum = parseFloat(newRange);
      setChannels(deepCpy);
  };

Or in case if you use lodash library

   const updateRanges = (rowI, minOrMax, newRange) => {
      const deepCpy = cloneDeep(channels)
      deepCpy[rowI].minimum = parseFloat(newRange);
      setChannels(deepCpy);
  };

You can also map through the array to update but I don't think that's the best solution to implement if the channels array size is change and get bigger.

And also add <input type="number" ... to the jsx element to prevent user from type characters

And add a key prop as well, by assuming rowData.name is unique, ( Don't use rowI as key prop )

{channels?.map((rowData, rowI) => {
        console.log(">>>looping through channels ");
        return (
          <div key={rowData.name}>
  // rest of the code
Dilshan
  • 2,797
  • 1
  • 8
  • 26