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>