Skip to content
Advertisement

Functional component not updating DOM when updating state with useState

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?

Advertisement

Answer

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 correctly update your state you should be using something like .map() (as suggested by the React documentation) to create a new array that has its own reference that is unique and different from your current channels state reference. 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:

const updateRanges = (rowI, minOrMax, newRange) => {
  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
  ));
};

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 both examples above, the .map() and the ([...channels]) => create new arrays that are a completely different references in memory to the original channels state. This means that when we return it from the state setter 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). That is why we’re creating a new object with

{...channel, [minOrMax]: parseFloat(newRange)}

rather than directly modifying the object with:

channel[minOrMax] = parseFloat(newRange); 

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 more 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 setter 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>
User contributions licensed under: CC BY-SA
8 People found this is helpful
Advertisement