What I am trying to do
I'm trying to use an array of objects (habits) and render a "Card" component from each one.
Now for each Card (habit) you can "mark" that habit as done, which will update that Card's state.
I've used React.memo to prevent other Cards from re-rendering.
Whenever the user mark a card as done, the card header gets changed to "Edited"
The Issue I'm facing
Whenever a card is marked as done, the header changes alright, but whenever any other card is marked as done, the first card's state gets reverted back.
I've not been able to find other people facing a similar issue, can somebody please help?
Here is the code:
import React, { useState } from "react";
const initialState = {
habits: [
{
id: "1615649099565",
name: "Reading",
description: "",
startDate: "2021-03-13",
doneTasksOn: ["2021-03-13"]
},
{
id: "1615649107911",
name: "Workout",
description: "",
startDate: "2021-03-13",
doneTasksOn: ["2021-03-14"]
},
{
id: "1615649401885",
name: "Swimming",
description: "",
startDate: "2021-03-13",
doneTasksOn: []
},
{
id: "1615702630514",
name: "Arts",
description: "",
startDate: "2021-03-14",
doneTasksOn: ["2021-03-14"]
}
]
};
export default function App() {
const [habits, setHabits] = useState(initialState.habits);
const markHabitDone = (id) => {
let newHabits = [...habits];
let habitToEditIdx = undefined;
for (let i = 0; i < newHabits.length; i++) {
if (newHabits[i].id === id) {
habitToEditIdx = i;
break;
}
}
let habit = { ...habits[habitToEditIdx], doneTasksOn: [], name: "Edited" };
newHabits[habitToEditIdx] = habit;
setHabits(newHabits);
};
return (
<div className="App">
<section className="test-habit-cards-container">
{habits.map((habit) => {
return (
<MemoizedCard
markHabitDone={markHabitDone}
key={habit.id}
{...habit}
/>
);
})}
</section>
</div>
);
}
const Card = ({
id,
name,
description,
startDate,
doneTasksOn,
markHabitDone
}) => {
console.log(`Rendering ${name}`);
return (
<section className="test-card">
<h2>{name}</h2>
<small>{description}</small>
<h3>{startDate}</h3>
<small>{doneTasksOn}</small>
<div>
<button onClick={() => markHabitDone(id, name)}>Mark Done</button>
</div>
</section>
);
};
const areCardEqual = (prevProps, nextProps) => {
const matched =
prevProps.id === nextProps.id &&
prevProps.doneTasksOn === nextProps.doneTasksOn;
return matched;
};
const MemoizedCard = React.memo(Card, areCardEqual);
Note: This works fine without using React.memo() wrapping on the Card component.
Here is the codesandbox link: https://codesandbox.io/s/winter-water-c2592?file=/src/App.js
Problem is because of your (custom) memoization markHabitDone becomes a stale closure in some components.
Notice how you pass markHabitDone to components. Now imagine you click one of the cards and mark it as done. Because of your custom memoization function other cards won't be rerendered, hence they will still have an instance of markHabitDone from a previous render. So when you update an item in a new card now:
let newHabits = [...habits];
the ...habits there is from previous render. So the old items are basically re created this way.
Using custom functions for comparison in memo like your areCardEqual function can be tricky exactly because you may forget to compare some props and be left with stale closures.
One of the solutions is to get rid of the custom comparison function in memo and look into using useCallback for the markHabitDone function. If you also use [] for the useCallback then you must rewrite markHabitDone function (using the functional form of setState) such that it doesn't read the habits using a closure like you have in first line of that function (otherwise it will always read old value of habits due to empty array in useCallback).
Related
So, I've got this view of animal cards called like this:
<AnimalsCard {...elephant}/>
<AnimalsCard {...hippo}/>
<AnimalsCard {...sugar_glider}/>
My AnimalCard code looks like this:
export const AnimalsCard = ({ id, animal, img }) => {
const { t } = useTranslation();
return (
<>
<CardContainer id={id} img={img}>
<CardContent>
<CardTitle>{animal}</CardTitle>
<CardButton to={`${id}`}>Dowiedz się więcej</CardButton>
</CardContent>
</CardContainer>
</>
)
};
And my animal objects look like that:
export const elephant = {
animal: 'Słoń indyjski',
id: 'slon_indyjski',
species: 'mammals',
img: require('../../../images/animals/mammals/elephant.jpg'),
occurance: '',
habitat: '',
diet: '',
reproduction: '',
conservationStatus: '',
funFactOne: ''
}
When I click the button, new page of the animal id opens up:
<Route path="/:id" component={AnimalsDetails} />
On AnimalsDetails page, I would like to display all of the animal object data. I unfortunately have no idea how to pass it because I'm a beginner and hardly know anything about props and stuff.
My current approach is that in the AnimalsDetails I retrieve the ID using useParams();
export const AnimalsDetails = () => {
const [mammal, setMammal] = useState(null);
let { id } = useParams();
useEffect(() => {
const mammal = mammalsData.filter(thisMammal => thisMammal.id === id);
setMammal(mammal);
// console.log(mammal[0].animal);
}, [id]);
return ( <AnimalsDetailsContainer>
{/* <div>{mammal[0].id}</div> */}
</AnimalsDetailsContainer>
)
};
export default AnimalsDetails;
And then using the ID from URL, I'm filtering my mammalsData array that I've created only to test if the approach works (I wish I could use the previously created objects but I don't know if it's possible). It looks like that:
export const mammalsData = [
{
[...]
},
{
animal: 'abc',
id: 'fgh',
species: 'idontknow',
img: require('whatimdoing.JPG'),
occurance: '',
habitat: '',
diet: '',
reproduction: '',
conservationStatus: '',
funFactOne: ''
}
];
For now it works only when I'm on the animals card page and click the button, so the ID is present. If I'm somewhere else on the page and my ID is not declared yet, I receive an error that the data I want to display is undefined, which makes sense obviously. I've tried wrapping the return of the AnimalsDetails to log an error if the ID is not present but it didn't work.
I know there is some decent way to do that (probably with props or hooks defined differently), but I really tried many stuff and I'm utterly lost now. I wish I could pass the data to AnimalsDetails within clicking the button or something but have no idea how to do that. :-(
I'll be grateful for any help. I know it's basic stuff, but it's my first website ever.
I understand your problems.
In my opinion you 'd like to focus useEffect method as below.
useEffect(() => {
if(id != undefined)
// console.log('error);
return (
<div>{'error'}</div>
);
const mammal = mammalsData.filter(thisMammal => thisMammal.id === id);
setMammal(mammal);
// console.log(mammal[0].animal);
}, [id]);
when you want to pass your data with url, in the react router v6 has that capability.
Let say this is your Animals component,
import { Link } from "react-router-dom";
//let assume this is the data that you need to send
const elephant = {
animal: "Słoń indyjski",
id: "slon_indyjski",
species: "mammals",
img: require("../../../images/animals/mammals/elephant.jpg"),
occurance: "",
habitat: "",
diet: "",
reproduction: "",
conservationStatus: "",
funFactOne: "",
};
const Animals = props => {
//elephant.id is dynamic value. You have to add that value dynamically.
return (
<Link to={`${elephant.id}`} state={{ singleAnimal: elephant }}>
<div>this is single animal</div>
</Link>
);
};
In the Link component you have to mention your dynamic route and you cant send your data from state attribute.
In the receiving end,(Let say your receiving end component is SingleanimalData.
import { useLocation } from "react-router-dom";
const SingleanimalData = props => {
const location = useLocation();
//animal variable now has that recieving data.so console it to view data
const animal = location.state.singleAnimal;
return (
<div>
<div>My animal name:</div>
<div> {animal.animal} </div>
</div>
);
};
In here you have to use useLocation hook to grab data. I hope you this make sense to you.
If you are using the latest react router (v6), use element instead of render (here):
<Route path="/:id" element={<AnimalsDetails animal={yourAnimal} />} />
Try this way.
In the parent component add this part
<Route path="/:id" render={()=> <AnimalsDetails item={...animaldata} />} />
In AnimalDetails component
import React, { useEffect } from 'react';
import {useParams} from 'react-router-dom';
const { animalId } = useParams();
let animal;
useEffect(()=> {
if(animalId && props.item) {
animal = props.item.filter(a => a.id === animalId)
}
}, [animalId]);
I'm trying to update a react state that holds nested values. I want to update data that is 3 levels deep.
Here is the state that holds the data:
const [companies, setCompanies] = useState(companies)
Here is the data for the first company (the companies array holds many companies):
const companies = [
{
companyId: 100,
transactions: [
{
id: "10421A",
amount: "850",
}
{
id: "1893B",
amount: "357",
}
}
]
Here is the code for the table component:
function DataTable({ editCell, vendors, accounts }) {
const columns = useMemo(() => table.columns, [table]);
const data = useMemo(() => table.rows, [table]);
const tableInstance = useTable({ columns, data, initialState: { pageIndex: 0 } }, useGlobalFilter, useSortBy, usePagination);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
rows,
page,
state: { pageIndex, pageSize, globalFilter },
} = tableInstance;
return (
<Table {...getTableProps()}>
<MDBox component="thead">
{headerGroups.map((headerGroup) => (
<TableRow {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<DataTableHeadCell
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
width={column.width ? column.width : "auto"}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
>
{column.render("Header")}
</DataTableHeadCell>
))}
</TableRow>
))}
</MDBox>
<TableBody {...getTableBodyProps()}>
{page.map((row, key) => {
prepareRow(row);
return (
<TableRow {...row.getRowProps()}>
{row.cells.map((cell) => {
cell.itemsSelected = itemsSelected;
cell.editCell = editCell;
cell.vendors = vendors;
cell.accounts = accounts;
return (
<DataTableBodyCell
noBorder={noEndBorder && rows.length - 1 === key}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{cell.render("Cell")}
</DataTableBodyCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
)
}
For example, I want to update the amount in the first object inside the transactions array. What I'm doing now is update the entire companies array, but doing this rerenders the whole table and creates problems. Is there a way I can only update the specific value in a manner that rerenders just the updated field in the table without rerendering the whole table? I've seen other answers but they assume that all values are named object properties.
FYI, I'm not using any state management and would prefer not to use one for now.
You have to copy data (at least shallow copy) to update state:
const nextCompanies = { ...companies };
nextCompanies.transactions[3].amount = 357;
setState(nextCompanies);
Otherwise react won't see changes to the original object. Sure thing you can use memoization to the child component to skip useless rerenders. But I strongly recommend to provide an optimisation only when it is needed to optimise. You will make the code overcomplicated without real profit.
When updating state based on the previous state, you probably want to pass a callback to setCompanies(). For example:
setCompanies((currCompanies) => {
const nextCompanies = [...currCompanies];
// modify nextCompanies
return nextCompanies;
})
Then, in order for React to only re-render the elements that changed in the DOM, you should make sure to set the key prop in each of those elements. This way, React will know which element changed.
// inside your component code
return (
<div>
companies.map(company => (
<Company key={company.id} data={company} />
))
</div>
)
Does this solve the problem? If not, it may be helpful to add some more details so we can understand it fully.
What I'm doing now is update the entire companies array, but doing
this rerenders the whole table and creates problems.
When you say it creates problems what type of problems exactly? How does re-rendering create problems? This is expected behavior. When state or props change, by default a component will re-render.
You seem to be asking two questions. The first, how to update state when only modifying a subset of state (an amount of a transaction). The second, how to prevent unnecessary re-rendering when render relies on state or props that hasn't changed. I've listed some strategies for each below.
1. What is a good strategy to update state when we only need to modify a small subset of it?
Using your example, you need to modify some data specific to a company in a list of companies. We can use map to iterate over each company and and conditionally update the data for the company that needs updating. Since map returns a new array, we can map over state directly without worrying about mutating state.
We need to know a couple things first.
What transaction are we updating?
What is the new amount?
We will assume we also want the company ID to identify the correct company that performed the transaction.
We could pass these as args to our function that will ultimately update the state.
the ID of the company
the ID of the transaction
the new amount
Any companies that don't match the company ID, we just return the previous value.
When we find a match for the company ID, we want to modify one of the transactions, but return a copy of all the other previous values. The spread operator is a convenient way to do this. The ...company below will merge a copy of the previous company object along with our updated transaction.
Transactions is another array, so we can use the same strategy with map() as we did before.
const handleChangeAmount = ({ companyId, transactionId, newAmount }) => {
setCompanies(() => {
return companies.map((company) => {
return company.id === companyId
? {
...company,
transactions: company.transactions.map((currTransaction) => {
return currTransaction.id === transactionId
? {
id: currTransaction.id,
amount: newAmount
}
: currTransaction;
})
}
: company;
});
});
};
2. How can we tell React to skip re-rendering if state or props hasn't changed?
If we are tasked with skipping rendering for parts of the table that use state that didn't change, we need a way of making that comparison within our component(s) for each individual company. A reasonable approach would be to have a reusable child component <Company /> that renders for each company, getting passed props specific to that company only.
Despite our child company only being concerned with its props (rather than all of state), React will still render the component whenever state is updated since React uses referential equality (whether something refers to the same object in memory) whenever it receives new props or state, rather than the values they hold.
If we want to create a stable reference, which helps React's rendering engine understand if the value of the object itself hasn't changed, the React hooks for this are useCallback() and useMemo()
With these hooks we can essentially say:
if we get new values from props, we re-render the component
if the values of props didn't change, skip re-rendering and just use the values from before.
You haven't listed a specific problem in your question, so it's unclear if these hooks are what you need, but below is a short summary and example solution.
From the docs on useCallback()
This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders
From the docs on useMemo()
This optimization helps to avoid expensive calculations on every render.
Demo/Solution
https://codesandbox.io/s/use-memo-skip-child-update-amount-vvonum
import { useState, useMemo } from "react";
const companiesData = [
{
id: 1,
transactions: [
{
id: "10421A",
amount: "850"
},
{
id: "1893B",
amount: "357"
}
]
},
{
id: 2,
transactions: [
{
id: "3532C",
amount: "562"
},
{
id: "2959D",
amount: "347"
}
]
}
];
const Company = ({ company, onChangeAmount }) => {
const memoizedCompany = useMemo(() => {
console.log(
`AFTER MEMOIZED CHECK COMPANY ${company.id} CHILD COMPONENT RENDERED`
);
return (
<div>
<p>Company ID: {company.id}</p>
{company.transactions.map((t, i) => {
return (
<div key={i}>
<span>id: {t.id}</span>
<span>amount: {t.amount}</span>
</div>
);
})}
<button onClick={onChangeAmount}> Change Amount </button>
</div>
);
}, [company]);
return <div>{memoizedCompany}</div>;
};
export default function App() {
const [companies, setCompanies] = useState(companiesData);
console.log("<App /> rendered");
const handleChangeAmount = ({ companyId, transactionId, newAmount }) => {
setCompanies(() => {
return companies.map((company) => {
return company.id === companyId
? {
...company,
transactions: company.transactions.map((currTransaction) => {
return currTransaction.id === transactionId
? {
id: currTransaction.id,
amount: newAmount
}
: currTransaction;
})
}
: company;
});
});
};
return (
<div className="App">
{companies.map((company) => {
return (
<Company
key={company.id}
company={company}
onChangeAmount={() =>
handleChangeAmount({
companyId: company.id,
transactionId: company.transactions[0].id,
newAmount: Math.floor(Math.random() * 1000)
})
}
/>
);
})}
</div>
);
}
Explanation
On mount, the child component renders twice, once for each company.
The button will update the amount on the first transaction just for that company.
When the button is clicked, only one <Company /> component will render while the other one will skip rendering and use the memoized value.
You can inspect the console to see this in action. Extending this scenario, if you had 100 companies, updating the amount for one company would result in 99 skipped re-renders with only one new component rendering for the updated company.
I have a react component that stores a set of fruits in useState. I have a memoized function (visibleFruits) that filters fruits. I map visibleFruits to the dom.
The problem is, when i check a fruit, all visible fruits re-render.
I am expecting that only the selected one re-renders since it is the only one that is changing.
Is there a way for me to use this pattern but prevent all from re-rendering on check?
In real life, there is a complex function in visibleFruits useMemo. So I can't simply append the filter before the map.
Edit, here is updated edit:
const Example = () => {
const [fruits, setFruits] = React.useState([
{ id: 1, name: 'apple', visible: true, selected: false },
{ id: 2, name: 'banana', visible: false, selected: false },
{ id: 3, name: 'orange', visible: true, selected: false }
])
const visibleFruits = React.useMemo(() => {
return fruits.filter((f) => f.visible)
}, [fruits])
const handleCheck = (bool, id) => {
setFruits((prev) => {
return prev.map((f) => {
if (f.id === id) {
f.selected = bool
}
return f
})
})
}
return (
<div>
{visibleFruits.map((fruit) => {
return <FruitOption fruit={fruit} handleCheck={handleCheck} />
})}
</div>
)
}
const FruitOption = ({ fruit, handleCheck }) => {
console.log('** THIS RENDERS TWICE EVERY TIME USER SELECTS A FRUIT **')
return (
<div key={fruit.id}>
<input
checked={fruit.selected}
onChange={(e) => handleCheck(e.target.checked, fruit.id)}
type='checkbox'
/>
<label>{fruit.name}</label>
</div>
)
}
export default Example
First, there's a problem with the handleCheck function (but it's not related to what you're asking about). Your code is modifying a fruit object directly (f.selected = bool), but you're not allowed to do that with React state, objects in state must not be directly modified, and rendering may not be correct if you break that rule. Instead, you need to copy the object and modify the copy (like you are with the array):
const handleCheck = (bool, id) => {
setFruits((prev) => {
return prev.map((f) => {
if (f.id === id) {
return {...f, selected: bool}; // ***
}
return f;
});
});
};
But that's not what you're asking about, just something else to fix. :-)
The reason you see the console.log executed twice after handleCheck is that the component has to be re-rendered (for the change), and there are two visible fruits, so you see two calls to your FruitOption component function. There are two reasons for this:
handleChange changes every time your Example component function is called, so FruitOption sees a new prop every time; and
FruitOption doesn't avoid re-rendering when its props don't change, so even once you've fixed #1, you'd still see two console.log calls; and
Separately, there's no key on the FruitOption elements, which can cause rendering issues. Always include a meaningful key when rendering elements in an array. (Don't just use the index, it's problematic; but your fruit objects have an id, which is perfect.)
To fix it:
Memoize handleChange so that it's not recreated every time, probably via useCallback, and
Use React.memo so that FruitOption doesn't get called if its props don't change (see the end of this answer for the class component equivalent), and
Add a meaningful key to the FruitOption elements in Example
Taking those and the handleChange fix above and putting them all together:
const Example = () => {
const [fruits, setFruits] = React.useState([
{ id: 1, name: 'apple', visible: true, selected: false },
{ id: 2, name: 'banana', visible: false, selected: false },
{ id: 3, name: 'orange', visible: true, selected: false }
]);
const visibleFruits = React.useMemo(() => {
return fruits.filter((f) => f.visible);
}, [fruits]);
const handleCheck = React.useCallback(
(bool, id) => {
setFruits((prev) => {
return prev.map((f) => {
if (f.id === id) {
return {...f, selected: bool}; // ***
}
return f;
});
});
},
[] // *** No dependencies since all it uses is `setFruits`, which is
// stable for the lifetime of the component
);
return (
<div>
{visibleFruits.map((fruit) => {
// *** Note the key
return <FruitOption key={fruit.id} fruit={fruit} handleCheck={handleCheck} />
})}
</div>
);
}
// *** `React.memo` will compare the props and skip the call if they're the same, reusing
// the previous call's result.
const FruitOption = React.memo(({ fruit, handleCheck }) => {
console.log(`Rendering fruit ${fruit.id}`);
return (
<div key={fruit.id}>
<input
checked={fruit.selected}
onChange={(e) => handleCheck(e.target.checked, fruit.id)}
type='checkbox'
/>
<label>{fruit.name}</label>
</div>
);
});
ReactDOM.render(<Example />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
As you can see, with all that in place, only the changed fruit is re-rendered.
Re React.memo: For components with more complicated requirements, you can provide a function as a second argument that determines whether the two sets of props are "the same" for rendering purposes. By default, React.memo just does a shallow equality comparison, which is often sufficient.
Finally: For class components, the equivalent of React.memo without providing an equality callback is extending PureComponent instead of Component. If you want to make your check of the props more fine-grained, you can implement shouldComponentUpdate instead.
My app allows users to click on player cards in a Field section, and then for the selected player cards to appear in a Teams section. I have an array (called selectedPlayers) and initially, each element has a default player name and default player image. As the users select players, the elements in the array are replaced one-by-one by the name and image of the selected players.
The state of the array is set in a parent component and then the array is passed to a TeamsWrapper component as a prop. I then map through the array, returning a TeamsCard component for each element of the array. However, my TeamsCards are always one selection behind reality. In other words, after the first player is selected, the first card still shows the default info; after the second player is selected, the first card now reflects the first selection, but the second card still shows the default info. The code for the TeamsWrapper component is below:
import React from "react";
import "./style.css";
import TeamsCard from "../TeamsCard";
function TeamsWrapper(props) {
const { selectedPlayers } = props;
console.log('first console.log',selectedPlayers)
return (
<div className="teamsWrapper">
{selectedPlayers.map((el, i) => {
console.log('second console.log',selectedPlayers)
console.log('third console.log',el)
return (
<div key={i}>
<TeamsCard
image={el.image}
playerName={el.playerName}
/>
</div>
);
})}
</div>
);
}
export default TeamsWrapper;
I did have this working fine before when the parent was a class-based component. However, I changed it to a function component using hooks for other purposes. So, I thought the issue was related to setting state, but the console logs indicate something else (I think). After the first player is selected:
the first console log shows a correctly updated array (i.e. the first element reflects the data for the selected player, not the placeholder data)
the second console log reflects the same
but the third print still shows the placeholder data for the first element
As mentioned above, as I continue to select players, this third print (and the TeamsCards) is always one selection behind.
EDIT:
Here is the code for the parent component (Picks), but I edited out the content that was not relevant to make it easier to follow.
import React, { useState } from "react";
import { useStateWithCallbackLazy } from "use-state-with-callback";
import TeamsWrapper from "../TeamsWrapper";
import FieldWrapper from "../FieldWrapper";
const Picks = () => {
const initialSelectedPlayers = [
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
];
const [count, setCount] = useStateWithCallbackLazy(0);
const [selectedPlayers, setSelectedPlayers] = useState(
initialSelectedPlayers
);
const handleFieldClick = (props) => {
// check to make sure player has not already been picked
const match = selectedPlayers.some(
(el) => el.playerName === props.playerName
);
if (match) {
return;
} else {
setCount(count + 1, (count) => {
updatePickPhase(props, count);
});
}
};
const updatePickPhase = (props, count) => {
if (count <= 15) {
updateTeams(props, count);
}
// elseif other stuff which doesn't apply to this issue
};
const updateTeams = (props, count) => {
const location = [0, 1, 2, 5, 4, 3];
const position = location[count - 1];
let item = { ...selectedPlayers[position] };
item.playerName = props.playerName;
item.image = props.image;
selectedPlayers[position] = item;
setSelectedPlayers(selectedPlayers);
};
return (
<>
<TeamsWrapper selectedPlayers={selectedPlayers}></TeamsWrapper>
<FieldWrapper
handleFieldClick={handleFieldClick}
count={count}
></FieldWrapper>
</>
);
};
export default Picks;
Thank you for your help!
When you update the array you are mutating state (updating existing variable instead of creating a new one), so React doesn't pick up the change and only re-render when count changes, try
const updateTeams = (props, count) => {
const position = count - 1;
const newPlayers = [...selectedPlayers];
newPlayers[position] = {playerName:props.playerName, image:props.image}
setSelectedPlayers(newPlayers);
};
This way React will see it is a new array and re-render.
I have a form with several layers of child components. The state of the form is maintained at the highest level and I pass down functions as props to update the top level. The only problem with this is when the form gets very large (you can dynamically add questions) every single component reloads when one of them updates. Here's a simplified version of my code (or the codesandbox: https://codesandbox.io/s/636xwz3rr):
const App = () => {
return <Form />;
}
const initialForm = {
id: 1,
sections: [
{
ordinal: 1,
name: "Section Number One",
questions: [
{ ordinal: 1, text: "Who?", response: "" },
{ ordinal: 2, text: "What?", response: "" },
{ ordinal: 3, text: "Where?", response: "" }
]
},
{
ordinal: 2,
name: "Numero dos",
questions: [
{ ordinal: 1, text: "Who?", response: "" },
{ ordinal: 2, text: "What?", response: "" },
{ ordinal: 3, text: "Where?", response: "" }
]
}
]
};
const Form = () => {
const [form, setForm] = useState(initialForm);
const updateSection = (idx, value) => {
const { sections } = form;
sections[idx] = value;
setForm({ ...form, sections });
};
return (
<>
{form.sections.map((section, idx) => (
<Section
key={section.ordinal}
section={section}
updateSection={value => updateSection(idx, value)}
/>
))}
</>
);
};
const Section = props => {
const { section, updateSection } = props;
const updateQuestion = (idx, value) => {
const { questions } = section;
questions[idx] = value;
updateSection({ ...section, questions });
};
console.log(`Rendered section "${section.name}"`);
return (
<>
<div style={{ fontSize: 18, fontWeight: "bold", margin: "24px 0" }}>
Section name:
<input
type="text"
value={section.name}
onChange={e => updateSection({ ...section, name: e.target.value })}
/>
</div>
<div style={{ marginLeft: 36 }}>
{section.questions.map((question, idx) => (
<Question
key={question.ordinal}
question={question}
updateQuestion={v => updateQuestion(idx, v)}
/>
))}
</div>
</>
);
};
const Question = props => {
const { question, updateQuestion } = props;
console.log(`Rendered question #${question.ordinal}`);
return (
<>
<div>{question.text}</div>
<input
type="text"
value={question.response}
onChange={e =>
updateQuestion({ ...question, response: e.target.value })
}
/>
</>
);
};
I've tried using useMemo and useCallback, but I can't figure out how to make it work. The problem is passing down the function to update its parent. I can't figure out how to do that without updating it every time the form updates.
I can't find a solution online anywhere. Maybe I'm searching for the wrong thing. Thank you for any help you can offer!
Solution
Using Andrii-Golubenko's answer and this article React Optimizations with React.memo, useCallback, and useReducer I was able to come up with this solution:
https://codesandbox.io/s/myrjqrjm18
Notice how the console log only shows re-rendering of components that have changed.
Use React feature React.memo for functional components to prevent re-render if props not changed, similarly to PureComponent for class components.
When you pass callback like that:
<Section
...
updateSection={value => updateSection(idx, value)}
/>
your component Section will rerender each time when parent component rerender, even if other props are not changed and you use React.memo. Because your callback will re-create each time when parent component renders. You should wrap your callback in useCallback hook.
Using useState is not a good decision if you need to store complex object like initialForm. It is better to use useReducer;
Here you could see working solution: https://codesandbox.io/s/o10p05m2vz
I would suggest using life cycle methods to prevent rerendering, in react hooks example you can use, useEffect. Also centralizing your state in context and using the useContext hook would probably help as well.
laboring through this issue with a complex form, the hack I implemented was to use onBlur={updateFormState} on the component's input elements to trigger lifting form data from the component to the parent form via a function passed as a prop to the component.
To update the component's input elelment, I used onChange={handleInput} using a state within the compononent, which component state was then passed ot the lifting function when the input (or the component as a whole, if there's multiple input field in the component) lost focus.
This is a bit hacky, and probably wrong for some reason, but it works on my machine. ;-)