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.
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>