I just started React, and in this Item list tutorial I have some question about updating the states of the item. Also, I'm using functional component .So in app.js
const [items, setItems] = useState([
{
id: 1,
title: 'Banana',
bought: false
},
...
])
Then I have a function in app.js to update the bought to true or false when I check a box
// The id is passed in from Item.js down below
const markBought = (id) => {
setItems(
items.map(
item => {
if (item.id === id) {
/// If bought is false, checking it will make it true and vice versa
item.bought = !item.bought; // (1)
}
return item; // (2)
})
);
};
return (
<div className="App">
<Items items={items} markBought={markBought}></Items>
</div>
);
The teacher said we are using something called Component Drilling. So in Items.js, we map through every item to display them one by one, but I don't think it is neccessary to show.
Finally in Item.js
<input type="checkbox" onChange={() => props.markBought(props.item.id)} />
{props.item.title}
The application worked perfectly, but it's a little bit confusing for me. So:
In app.js, after we change the bought status, shouldn't we also need to return item, the same way we return the item if the condition is false? Why only return the item when if is wrong, but when it is right we only change it without a return?
I read that map will not modify the array, so markBought function should create a new items array, with the bought modified already, but what happens to this array, how do React know to "props" this to item.js, rather than the ones I hard coded?
Sorry if this is a little bit long, any help will be really appreciated. Thanks for reading
You are mutating an item in your map, if you optimized your Item component to be a pure component then that component won't re render because of the mutation. Try the following instead:
//use useCallback so marBought doesn't change and cause
// needless DOM re renders
const markBought = useCallback(id => {
setItems((
items //pass callback to the setter from useState
) =>
items.map(
item =>
item.id === id
? { ...item, bought: !item.bought } //copy item with changed value
: item //not this item, just return the item
)
);
}, []);
Here is a full example:
const { useCallback, useState, useRef, memo } = React;
function Items() {
const [items, setItems] = useState([
{
id: 1,
title: 'Banana',
bought: false,
},
{
id: 2,
title: 'Peach',
bought: false,
},
]);
const toggleBought = useCallback(id => {
setItems((
items //pass callback to the setter from useState
) =>
items.map(
item =>
item.id === id
? { ...item, bought: !item.bought } //copy item with changed value
: item //not this item, just return the item
)
);
}, []);
return (
<div>
{items.map(item => (
<Item
key={item.id}
item={item}
toggleBought={toggleBought}
/>
))}
</div>
);
}
//use memo to make Item a pure component
const Item = memo(function Item({ item, toggleBought }) {
const renderedRef = useRef(0);
renderedRef.current++;
return (
<div>
<div>{item.title}</div>
<div>bought: {item.bought ? 'yes' : 'no'}</div>
<button onClick={() => toggleBought(item.id)}>
toggle bought
</button>
<div>Rendered: {renderedRef.current} times</div>
</div>
);
});
//render the application
ReactDOM.render(<Items />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Here is a broken example where you mutate the item and won't see the re render even though the state did change:
const { useCallback, useState, useRef, memo } = React;
function Items() {
const [items, setItems] = useState([
{
id: 1,
title: 'Banana',
bought: false,
},
{
id: 2,
title: 'Peach',
bought: false,
},
]);
const toggleBought = useCallback(id => {
setItems((
items //pass callback to the setter from useState
) =>
items.map(
item =>
item.id === id
? ((item.bought = !item.bought),item) //mutate item
: item //not this item, just return the item
)
);
}, []);
return (
<div>
<div>
{items.map(item => (
<Item
key={item.id}
item={item}
toggleBought={toggleBought}
/>
))}
</div>
<div>{JSON.stringify(items)}</div>
</div>
);
}
//use memo to make Item a pure component
const Item = memo(function Item({ item, toggleBought }) {
const renderedRef = useRef(0);
renderedRef.current++;
return (
<div>
<div>{item.title}</div>
<div>bought: {item.bought ? 'yes' : 'no'}</div>
<button onClick={() => toggleBought(item.id)}>
toggle bought
</button>
<div>Rendered: {renderedRef.current} times</div>
</div>
);
});
//render the application
ReactDOM.render(<Items />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Hi there and welcome to Stackoverflow.
You are always returning the item. You just have an if statement that will change the bought state and item will get returned even if the condition above was false, which is the correct way of doing.
Map will indeed not modify the array but return a new one. If you want to get that returning array you could simply do :
const myNewArray = items.map(...)
The way this new array is getting to your other component is because this new array is given to your useState().
You see setItems() ? This will set your state and Item.js will automatically be updated.
That is what is so great about react. All components that are served from state will be updated once this state is updated.
Your map function always returns an item. It just modifies the item first if the item you're modifying matches the id of the item currently being mapped. Map returns a new array of items (even if it doesn't change anything), which causes useState to see a new value. By default, in React, the check for updates isn't very clever - it's just checking if oldValue === newValue.
For primitives like strings, object equality tests return true for two separate objects, as long as their values match.
"foo" === "foo" // => true
However, this isn't true for object or arrays. Two different arrays containing the same values will not compare as equal (because Javascript isn't comparing their contents, but rather their object IDs):
["foo"] === ["foo"] // => false
So, when you map your items, you get a new array object (because recall: map collects the return values of the callback function into a new array), which will never match the previous value of items, so every call to setItems will cause React to say "hm, my previous items isn't the same object as my new items, I must re-render this component".
Related
I have a newbie question on SolidJS. I have an array with objects, like a to-do list. I render this as a list with input fields to edit one of the properties in these objects. When typing in one of the input fields, the input directly loses focus though.
How can I prevent the inputs to lose focus when typing?
Here is a CodeSandbox example demonstrating the issue: https://codesandbox.io/s/6s8y2x?file=/src/main.tsx
Here is the source code demonstrating the issue:
import { render } from "solid-js/web";
import { createSignal, For } from 'solid-js'
function App() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'cleanup' },
{ id: 2, text: 'groceries' },
])
return (
<div>
<div>
<h2>Todos</h2>
<p>
Problem: whilst typing in one of the input fields, they lose focus
</p>
<For each={todos()}>
{(todo, index) => {
console.log('render', index(), todo)
return <div>
<input
value={todo.text}
onInput={event => {
setTodos(todos => {
return replace(todos, index(), {
...todo,
text: event.target.value
})
})
}}
/>
</div>
}}
</For>
Data: {JSON.stringify(todos())}
</div>
</div>
);
}
/*
* Returns a cloned array where the item at the provided index is replaced
*/
function replace<T>(array: Array<T>, index: number, newItem: T) : Array<T> {
const clone = array.slice(0)
clone[index] = newItem
return clone
}
render(() => <App />, document.getElementById("app")!);
UPDATE: I've worked out a CodeSandbox example with the problem and the three proposed solutions (based on two answers): https://codesandbox.io/s/solidjs-input-field-loses-focus-when-typing-itttzy?file=/src/App.tsx
<For> components keys items of the input array by the reference.
When you are updating a todo item inside todos with replace, you are creating a brand new object. Solid then treats the new object as a completely unrelated item, and creates a fresh HTML element for it.
You can use createStore instead, and update only the single property of your todo object, without changing the reference to it.
const [todos, setTodos] = createStore([
{ id: 1, text: 'cleanup' },
{ id: 2, text: 'groceries' },
])
const updateTodo = (id, text) => {
setTodos(o => o.id === id, "text", text)
}
Or use an alternative Control Flow component for mapping the input array, that takes an explicit key property:
https://github.com/solidjs-community/solid-primitives/tree/main/packages/keyed#Key
<Key each={todos()} by="id">
...
</Key>
While #thetarnav solutions work, I want to propose my own.
I would solve it by using <Index>
import { render } from "solid-js/web";
import { createSignal, Index } from "solid-js";
/*
* Returns a cloned array where the item at the provided index is replaced
*/
function replace<T>(array: Array<T>, index: number, newItem: T): Array<T> {
const clone = array.slice(0);
clone[index] = newItem;
return clone;
}
function App() {
const [todos, setTodos] = createSignal([
{ id: 1, text: "cleanup" },
{ id: 2, text: "groceries" }
]);
return (
<div>
<div>
<h2>Todos</h2>
<p>
Problem: whilst typing in one of the input fields, they lose focus
</p>
<Index each={todos()}>
{(todo, index) => {
console.log("render", index, todo());
return (
<div>
<input
value={todo().text}
onInput={(event) => {
setTodos((todos) => {
return replace(todos, index, {
...todo(),
text: event.target.value
});
});
}}
/>
</div>
);
}}
</Index>
Dat: {JSON.stringify(todos())}
</div>
</div>
);
}
render(() => <App />, document.getElementById("app")!);
As you can see, instead of the index being a function/signal, now the object is. This allows the framework to replace the value of the textbox inline.
To remember how it works: For remembers your objects by reference. If your objects switch places then the same object can be reused. Index remembers your values by index. If the value at a certain index is changed then that is reflected in the signal.
This solution is not more or less correct than the other one proposed, but I feel this is more in line and closer to the core of Solid.
With For, whole element will be re-created when the item updates. You lose focus when you update the item because the element (input) with the focus gets destroyed, along with its parent (li), and a new element is created.
You have two options. You can either manually take focus when the new element is created or have a finer reactivity where element is kept while the property is updated. The indexArray provides the latter out of the box.
The indexArray keeps the element references while updating the item. The Index component uses indexArray under the hood.
function App() {
const [todos, setTodos] = createSignal([
{ id: 1, text: "cleanup" },
{ id: 2, text: "groceries" }
]);
return (
<ul>
{indexArray(todos, (todo, index) => (
<li>
<input
value={todo().text}
onInput={(event) => {
const text = event.target.value;
setTodos(todos().map((v, i) => i === index ? { ...v, text } : v))
}}
/>
</li>
))}
</ul>
);
}
Note: For component caches the items internally to avoid unnecessary re-renders. Unchanged items will be re-used but updated ones will be re-created.
I am creating a simple tracker which records all the activities done. It is my first project in react. I have created three state one for storing all the items(name of state is list), one for pending items(name of state is pending) , one for completed items(name of state is completed). The items have a button which when clicked marks it into done state and vice-versa. It is completely rendering items for main list. But for other two its not rendering. When I am checking with react developer tools, it is working fine, i.e. it is adding to pending list or completed list as it should. But it is not compiling them on screen. list is already filled with items. I have added all the code for just in case.
function Filters(props){
const [completed, setCompleted] = useState([]);
const [pending, setPending] = useState([]);
const [state, setState] = useState("None");
const [list,setList] = useState([]);
function viewState(){
setState("View-all");
}
//it is getting the clicked item id and marking it complete in main list
function markComplete(id){
list.map((items,index)=>{
if(index===id){
if(items.done===true)
items.done = false;
else{
items.done=true;
}
}
})
}
//i am simply scanning the main list and the items which are pending will be added to this list. //this happens whenever the person click on pending button
function pendingState(){
setState("pending-all");
setPending([]);
list.map(items=>{
if(items.done!==true){
setPending(prev=>{
return [...prev,items];
})
}
})
}
function completedState(){
setState("completed-all");
setCompleted([]);
list.map(items=>{
if(items.done===true){
setCompleted(prev=>{
return [...prev,items];
})
}
})
}
return (
<div>
<div className="input-section">
<Note setList={setList} />
</div>
<button type="button" onClick={viewState} >View All</button>
<button type="button" onClick={completedState}>Completed</button>
<button type="button" onClick={pendingState}>Pending</button>
<div>
{
(()=>{
if(state==="View-all")
{
return (
<div>
<h1>Working {completed}</h1>
{(list).map((items,index)=>
{
return (
<Excercise
key={index}
id={index}
title={items.name}
list={props.list}
setList={props.setList}
content={items.desp}
markComplete={markComplete}
/>
)
})}
</div>
)
}
else if(state==="completed-all")
{
return (
<div>
{completed.map((items,index)=>{
<Excercise
key={index}
id={index}
title={items.name}
list={props.list}
setList={props.setList}
content={items.desp}
markComplete={markComplete}
/>
})}
</div>
)
}
})()
}
</div>
</div>);
}
Kindly help. Thank you.
Hi #DREW
The function code :
function markComplete(id){
setList(lists=>{
lists.map(item=>{
return item.id===id ?{...item,done: !item.done} : (item);})
}
)
}
When I am using it instead of
const markComplete = (id) => {
setList((list) =>
list.map((item) =>
item.id === id
? {
...item,
done: !item.done
}
: item
)
);
};
it is showing, "Cannot read properties of undefined (reading 'filter')"
arent the both same. If not, what am I doing wrong. Sorry for bugging so many times, I have just started with react.
I think you've overcomplicated things a bit. You only need one array to store the exercises in, the "pending" and "completed" states are easily derived from the list state and the state filter state value.
Issues
markComplete callback is mutating the list state. When updating the list state not only is a new array reference necessary, but also new element object references are necessary for the elements that are being updated.
Uses poor boolean comparisons to set a boolean value. You can either toggle a boolean or set the value to the result of a boolean expression.
Use the viewState, pendingState, and completedState handlers to simply set the filter value, and then derive the computed state when rendering by adding an inline filter function.
Use the exercise id property as a React key and as the property used for toggling the completed (done) state.
Solution
function Filters(props) {
const [state, setState] = useState("None");
const [list, setList] = useState([
...
]);
function viewState() {
setState("View-all");
}
function pendingState() {
setState("pending-all");
}
function completedState() {
setState("completed-all");
}
const markComplete = (id) => {
setList((list) =>
list.map((item) =>
item.id === id
? {
...item,
done: !item.done
}
: item
)
);
};
return (
<div>
<div className="input-section">
<Note setList={setList} />
</div>
<button type="button" onClick={viewState}>
View All
</button>
<button type="button" onClick={completedState}>
Completed
</button>
<button type="button" onClick={pendingState}>
Pending
</button>
<div>
{list
.filter((item) => {
if (state === "pending-all") {
return !item.done;
} else if (state === "completed-all") {
return item.done;
}
return true;
})
.map((item) => (
<Excercise
key={item.id}
id={item.id}
done={item.done}
title={item.name}
content={item.desp}
markComplete={markComplete}
/>
))}
</div>
</div>
);
}
try to add dependecies in useEffect
in this function you are mutating a state, so in order to do so you need to use the setState function, in this case, it will be setList().
function markComplete(id){
list.map((items,index)=>{
if(index===id){
if(items.done===true)
items.done = false;
else{
items.done=true;
}
}
})
}
So a better way to implement this function could be, and remember, everything time you need to update a state you don't want to change the state directly, instead, you should make a copy and set the state to that copy
function markComplete(id){
const newList = [...list];
newList.map((items,index)=>{
if(index===id){
if(items.done===true)
items.done = false;
else{
items.done=true;
}
}
}
setList(newList)
}
The reason of your app not updating is because when your state changes react is not re-rendering it again.
so use useEffect, there are many hooks which can be used as per requirement.
try putting this line of code
useEffect( ( ) => {
console.log( 'Check console' );
}, [ dependency_here ] );
in dependency_here try adding state, completed, pending one by one and see the result.
You can also add multiple dependencies like [ state, pending, etc.. ];
Try on your own you'll understand it faster.
Hope hint will help you!
const TestScreen = (props) => {
const [data, setData] = useState([
{ "id": "0", "user": "Lisa","amount": 1 },
]);
return(
<View style={{
flex:1,
alignItems:'center',
justifyContent:'center'
}}>
<Text>amount : {data[0].amount}</Text>
<Text>User : {data[0].user}</Text>
<Button title="update" onPress={()=>setData(??????)}></Button>
</View>
)
}
export default TestScreen;
what is the best way to add an amount number on the user Lisa? i can do
// setData([{ "id": "0", "user": "Lisa","amount": data[0].amount + 1}])
but what i have 5 users or 20?
even with a returning function nothing gets updated exept console logged witch show me the actual value
let countAddFunc =(getArr)=>{
let arr = getArr
arr[0].amount++
console.log(arr[0].amount);
return(
arr
)
}
<Button title="update" onPress={()=>setData(countAddFunc(data))}></Button>
One of the key concepts in React is Do Not Modify State Directly. This can be tricky sometimes, especially when dealing with nested data like in your example (a number as a property of an object inside an array).
Below, I refactored your code and added comments to help explain. By creating a function to update the state, and passing that function to each child component's props, the child component will be able to update the state by calling the function.
const {useState} = React;
// component dedicated to displaying each item in the array
const Item = (props) => (
<div style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}>
<div>Amount: {props.item.amount}</div>
<div>User: {props.item.user}</div>
<button onClick={() => {
props.updateAmount(props.item.user);
}}>Increment</button>
</div>
);
const ItemList = () => {
const [data, setData] = useState([
{
id: '0',
user: 'Lisa',
amount: 1,
},
{
id: '1',
user: 'Walter',
amount: 3,
},
]);
const updateAmount = (user, changeAmount = 1) => {
// find the index of the item we want
const index = data.findIndex(item => item.user === user);
// return early (do nothing) if it doesn't exist
if (index === -1) return;
const item = data[index];
// don't modify state directly (item is still the same object in the state array)
const updatedItem = {
...item,
amount: item.amount + changeAmount,
};
// again, don't modify state directly: create new array
const updatedArray = [...data];
// insert updated item at the appropriate index
updatedArray[index] = updatedItem;
setData(updatedArray);
};
return (
<ul>
{data.map(item => (
<li key={item.id}>
<Item item={item} updateAmount={updateAmount} />
</li>
))}
</ul>
);
};
ReactDOM.render(<ItemList />, document.getElementById('root'));
<script src="https://unpkg.com/react#17.0.2/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#17.0.2/umd/react-dom.development.js"></script>
<div id="root"></div>
can you try like this once, pass second param as user id you want to update
<Button title="update" onPress={()=>setData(countAddFunc(data, 0))}></Button>
let countAddFunc =(getArr, id)=>{
const arrCopy = [...getArr]
const user = arrCopy.find(u => u.id === id )
if (user) {
user.amount++
}
return arrCopy
}
actually you are modifying the state directly, and we can not update state directly and getArr is just a refrence to the data in state, so, we created a copy of array, and modified copied array, and then we set this new array into the state, about the code throwing undefined error, add a check, if (user) user.amount++ and make sure id you send onPress={()=>setData(countAddFunc(data, 0))} actually exist
Think of setState() as a request rather than an immediate command to update the component. For better perceived performance, React may delay it, and then update several components in a single pass. React does not guarantee that the state changes are applied immediately. setState() does not always immediately update the component. It may batch or defer the update until later. This makes reading this.state right after calling setState() a potential pitfall.
Read the official documentation for more info here
Also, alternatively you can use useRef or useEffect with dependency array if you want the callback to immediately fire and do the changes.
After reading the docs for useState and useEffect I cant figure out what i'm doing wrong here... Im trying to dynamically update my h1 title with an updated title when a tab is clicked, however the state will not update so my title wont rerender.
This is my subheader component which takes in an array of objects as props. These objects are iterated over and used to populate the subnav bar. (which works as intended).
const Subheader = (props) => {
const {
submenuItems = []
} = props;
// State
const [pageTitle, setPageTitle] = useState(submenuItems[0].name); //Sets starting value as the first item of my submenu which is also the default route so works as intended.
const handleMenuItemClick = (name) => {
setPageTitle(name)
console.log(name) //This prints out the updated expected value
console.log(pageTitle) //This prints out the original not updated value
}
const submenuItemsJSX = submenuItems.map((item, index) => {
return (
<li
key={index}
to={item.to}
onClick={() => handleMenuItemClick(item.name)}
>
<a>
{item.name}
</a>
</li>
)
});
useEffect(() => {
console.log(pageTitle) //This prints out the original not updated value
}, [pageTitle])
return (
<div>
<div>
<h1>
{pageTitle} //This is what i want to update
</h1>
</div>
<div>
<ul>
{submenuItemsJSX}
</ul>
</div>
</div>
)
}
export default Subheader
a sample of whats coming in through the subMenuItems:
{name: 'Categories', to: '/settings/categories', buttons: [] }
setSelectedMenuItem and setPageTitle are the asynchronous method, and you can't get the updated value of selected and pageTitle immediately after setSelectedMenuItem() and setPageTitle().
You should use useEffect to check the updated value with adding dependency.
useEffect(() => {
console.log(selected)
}, [selected])
useEffect(() => {
console.log(pageTitle)
}, [pageTitle])
Your code appears to be correct. The issue must be somewhere else.
I've made a codesandbox demo and everything works.
Have you tried to pass empty string '' to useState, when you declare the pageTitle? And for initialisation of the value you can use useEffect hook.
const [pageTitle, setPageTitle] = useState('');
useEffect(() => {
setPageTitle(submenuItems[0].name)
})
List of items to render
Given a list of items (coming from the server):
const itemsFromServer = {
"1": {
id: "1",
value: "test"
},
"2": {
id: "2",
value: "another row"
}
};
Function component for each item
We want to render each item, but only when necessary and something changes:
const Item = React.memo(function Item({ id, value, onChange, onSave }) {
console.log("render", id);
return (
<li>
<input
value={value}
onChange={event => onChange(id, event.target.value)}
/>
<button onClick={() => onSave(id)}>Save</button>
</li>
);
});
ItemList function component with a handleSave function that needs to be memoized.
And there is a possibility to save each individual item:
function ItemList() {
const [items, setItems] = useState(itemsFromServer);
const handleChange = useCallback(
function handleChange(id, value) {
setItems(currentItems => {
return {
...currentItems,
[id]: {
...currentItems[id],
value
}
};
});
},
[setItems]
);
async function handleSave(id) {
const item = items[id];
if (item.value.length < 5) {
alert("Incorrect length.");
return;
}
await save(item);
alert("Save done :)");
}
return (
<ul>
{Object.values(items).map(item => (
<Item
key={item.id}
id={item.id}
value={item.value}
onChange={handleChange}
onSave={handleSave}
/>
))}
</ul>
);
}
How to prevent unnecessary re-renders of each Item when only one item changes?
Currently on each render a new handleSave function is created. When using useCallback the items object is included in the dependency list.
Possible solutions
Pass value as parameter to handleSave, thus removing the items object from the dependency list of handleSave. In this example that would be a decent solution, but for multiple reasons it's not preferred in the real life scenario (eg. lots more parameters etc.).
Use a separate component ItemWrapper where the handleSave function can be memoized.
function ItemWrapper({ item, onChange, onSave }) {
const memoizedOnSave = useCallback(onSave, [item]);
return (
<Item
id={item.id}
value={item.value}
onChange={onChange}
onSave={memoizedOnSave}
/>
);
}
With the useRef() hook, on each change to items write it to the ref and read items from the ref inside the handleSave function.
Keep a variable idToSave in the state. Set this on save. Then trigger the save function with useEffect(() => { /* save */ }, [idToSave]). "Reactively".
Question
All of the solutions above seem not ideal to me. Are there any other ways to prevent creating a new handleSave function on each render for each Item, thus preventing unnecessary re-renders? If not, is there a preferred way to do this?
CodeSandbox: https://codesandbox.io/s/wonderful-tesla-9wcph?file=/src/App.js
The first question I'd like to ask : is it really a problem to re-render ?
You are right that react will re-call every render for every function you have here, but your DOM should not change that much it might not be a big deal.
If you have heavy calculation while rendering Item, then you can memoize the heavy calculations.
If you really want to optimize this code, I see different solutions here:
Simplest solution : change the ItemList to a class component, this way handleSave will be an instance method.
Use an external form library that should work fine: you have powerfull form libraries in final-form, formik or react-hook-form
Another external library : you can try recoiljs that has been build for this specific use-case
Wow this was fun! Hooks are very different then classes. I got it to work by changing your Item component.
const Item = React.memo(
function Item({ id, value, onChange, onSave }) {
console.log("render", id);
return (
<li>
<input
value={value}
onChange={event => onChange(id, event.target.value)}
/>
<button onClick={() => onSave(id)}>Save</button>
</li>
);
},
(prevProps, nextProps) => {
// console.log("PrevProps", prevProps);
// console.log("NextProps", nextProps);
return prevProps.value === nextProps.value;
}
);
By adding the second parameter to React.memo it only updates when the value prop changes. The docs here explain that this is the equivalent of shouldComponentUpdate in classes.
I am not an expert at Hooks so anyone who can confirm or deny my logic, please chime in and let me know but I think that the reason this needs to be done is because the two functions declared in the body of the ItemList component (handleChange and handleSave) are in fact changing on each render. So when the map is happening, it passes in new instances each time for handleChange and handleSave. The Item component detects them as changes and causes a render. By passing the second parameter you can control what the Item component is testing and only check for the value prop being different and ignore the onChange and onSave.
There might be a better Hooks way to do this but I am not sure how. I updated the code sample so you can see it working.
https://codesandbox.io/s/keen-roentgen-5f25f?file=/src/App.js
I've gained some new insights (thanks Dan), and I think I prefer something like this below. Sure it might look a bit complicated for such a simple hello world example, but for real world examples it might be a good fit.
Main changes:
Use a reducer + dispatch for keeping state. Not required, but to make it complete. Then we don't need useCallback for the onChange handler.
Pass down dispatch via context. Not required, but to make it complete. Otherwise just pass down dispatch.
Use an ItemWrapper (or Container) component. Adds an additional component to the tree, but provides value as the structure grows. It also reflects the situation we have: each item has a save functionality that requires the entire item. But the Item component itself does not. ItemWrapper might be seen as something like a save() provider in this scenario ItemWithSave.
To reflect a more real world scenario there is now also a "item is saving" state and the other id that's only used in the save() function.
The final code (also see: https://codesandbox.io/s/autumn-shape-k66wy?file=/src/App.js).
Intial state, items from server
const itemsFromServer = {
"1": {
id: "1",
otherIdForSavingOnly: "1-1",
value: "test",
isSaving: false
},
"2": {
id: "2",
otherIdForSavingOnly: "2-2",
value: "another row",
isSaving: false
}
};
A reducer to manage state
function reducer(currentItems, action) {
switch (action.type) {
case "SET_VALUE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
value: action.value
}
};
case "START_SAVE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
isSaving: true
}
};
case "STOP_SAVE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
isSaving: false
}
};
default:
throw new Error();
}
}
Our ItemList to render all items from the server
export default function ItemList() {
const [items, dispatch] = useReducer(reducer, itemsFromServer);
return (
<ItemListDispatch.Provider value={dispatch}>
<ul>
{Object.values(items).map(item => (
<ItemWrapper key={item.id} item={item} />
))}
</ul>
</ItemListDispatch.Provider>
);
}
The main solution ItemWrapper or ItemWithSave
function ItemWrapper({ item }) {
const dispatch = useContext(ItemListDispatch);
const handleSave = useCallback(
// Could be extracted entirely
async function save() {
if (item.value.length < 5) {
alert("Incorrect length.");
return;
}
dispatch({ type: "START_SAVE", id: item.id });
// Save to API
// eg. this will use otherId that's not necessary for the Item component
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({ type: "STOP_SAVE", id: item.id });
},
[item, dispatch]
);
return (
<Item
id={item.id}
value={item.value}
isSaving={item.isSaving}
onSave={handleSave}
/>
);
}
Our Item
const Item = React.memo(function Item({ id, value, isSaving, onSave }) {
const dispatch = useContext(ItemListDispatch);
console.log("render", id);
if (isSaving) {
return <li>Saving...</li>;
}
function onChange(event) {
dispatch({ type: "SET_VALUE", id, value: event.target.value });
}
return (
<li>
<input value={value} onChange={onChange} />
<button onClick={onSave}>Save</button>
</li>
);
});