Well, I am trying to figure out how to play with an array within useEffect hook. Basically, I want to know how to pass index as a parameter so I don't have to grab the indexes manually.
I am fetching some data from the backend in order to edit some form fields.
The field I am having trouble, is a field that you could create dynamically. There could be only 1 or 100.
This is the component:
// HERE IS WHERE I AM ATTEMPTING TO DO WHAT I NEED
useEffect(() => {
// So there is only one name key.
// I need that to be dynamic. To accept as many name keys as possible.
startupFourthStepFormActionHandler({
products_or_services:
[
{
name:
startupProductsOrServicesInfo.productsorservices &&
startupProductsOrServicesInfo.productsorservices[0].name,
},
] || [],
});
}, []);
return (
<div>
{productsOrServicesInputs.map((input, index) => (
<div key={input}>
// THESE ARE DYNAMIC INPUTS. SO THERE COULD 1 OR 100
<FormField
value={startupFourthStepForm.products_or_services[index].name}
/>
// LET'S SAY THIS WAS CREATED DYNAMICALLY
<FormField
value={startupFourthStepForm.products_or_services[index].name}
/>
</div>
))}
</div>
);
};
If you see the component above, on useEffect I am doing this:
return startupFourthStepFormActionHandler({
products_or_services:
[
{
name:
startupProductsOrServicesInfo.productsorservices &&
startupProductsOrServicesInfo.productsorservices[0].name,
},
] || [],
});
I would like to turn that into something like:
return startupFourthStepFormActionHandler({
products_or_services:
[
{
name:
startupProductsOrServicesInfo.productsorservices &&
startupProductsOrServicesInfo.productsorservices[INDEX].name,
},
] || [],
});
Or something which allows me to have as many name keys as needed. Like:
return startupFourthStepFormActionHandler({
products_or_services:
[Here I should grab an array of 100 hundred objects with key called name] || [],
});
So I can do something like:
startupProductsOrServicesInfo.productsorservices[index].name
As you see I have this startupProductsOrServicesInfo.productsorservices[0].name but I need that be the proepr index of the item in the array. For now it is only grabbing the index 0, I need that index to be grabbed dynamically.
In the useEffect method on the component, you may see this
startupProductsOrServicesInfo.productsorservices which is an API call and returns this ->
{
"success": true,
"productsorservices": [
{
"name": "Software",
},
{
"name": "Hardware",
}
]
}
So all I am trying to is to set the value coming from the backend here on this value ->
value={startupFourthStepForm.products_or_services[index].name} which you may in the component <FormField />.
I need to do that in the useEffect hook.
What am I missing?
TL;DR
For no the component is doing what I need, but it only grabs the first index of the array, startupProductsOrServicesInfo.productsorservices[0].name and I need it to be grabbed dynamically.
Fundamentally useEffect hooks are just a way to control the timing of certain code. Otherwise they're not special at all, and so to solve your problem you just need regular old React solutions.
If you want to pass the index into your component, you simply do so with props:
const YourComponent = props => {
// HERE IS WHERE I AM ATTEMPTING TO DO WHAT I NEED
useEffect(() => {
// ..
startupProductsOrServicesInfo.productsorservices[props.index].name,
// ..
}
And then in pass the index in in your parent component:
return <YourComponent index={1} />
If you want your component to remember the index, you have to use state instead:
const YourComponent = () => {
const [index, setIndex] = useState(0);
// HERE IS WHERE I AM ATTEMPTING TO DO WHAT I NEED
useEffect(() => {
// ..
startupProductsOrServicesInfo.productsorservices[index].name,
If you want an event to change that index, for instance if you wanted a button in your component to increment it, you would use the setIndex function returned by useState:
const YourComponent = () => {
const [index, setIndex] = useState(0);
// HERE IS WHERE I AM ATTEMPTING TO DO WHAT I NEED
useEffect(() => {
// ..
startupProductsOrServicesInfo.productsorservices[index].name,
});
const incrementIndex = () => setIndex(index + 1);
return <button onClick={incrementIndex}>Click Me!</button>;
Related
I would like to write an App that allows multiple components to add and remove entries to a set by accessing the global context.
So far I am initialising a Set with useState and feeding the state and the changeState values down to the child components using a context provider, I am then adding and removing items from the Set based on the props passed down into that component.
The code:
The main App file
/App.js
export const myListContext = createContext(new Set());
function App() {
const [myList, changeMyList] = useState(() => new Set());
const Alphabet = {
A: 'a',
B: 'b',
};
useEffect(() => {
console.log(myList);
}, [myList]);
return (
<myListContext.Provider value={{ myList, changeMyList }}>
<div className="my-box">
<Checkbox myEnum={Alphabet.A} title={"Add A to Set"}></Checkbox>
<Checkbox myEnum={Alphabet.B} title={"Add B to Set"}></Checkbox>
</div>
</myListContext.Provider>
);
}
/Checkbox.js
const Checkbox = ({ myEnum, title }) => {
const { myList, changeMyList } = useContext(myListContext);
const [index, setIndex] = useState(0);
function changeIndex() {
setIndex(index + 1);
if (index >= 3) {
setIndex(1);
}
}
function addListItem(item) {
changeMyList((prev) => new Set(prev).add(item));
}
function removeMyListItem(item) {
changeMyList((prev) => {
const next = new Set(prev);
next.delete(item);
return next;
});
}
function filterItem(item) {
changeIndex();
if (index === 0) {
addListItem(item);
}
if (index === 1) {
removeMyListItem(item);
}
}
return (
<div className="my-checkbox">
<div
className="my-checkbox-inner"
onClick={() => {
filterItem(myEnum);
}}
></div>
<div className="the-title">{title}</div>
</div>
);
}
The reason for this set up is to Add or Remove Items based on how many times the user has pressed the div, each component needs to track the status of its own index.
Unfortunately, the effect hook on the top layer of the app reports that myList does not contain the new entries.
Why does changeMyList not have a global impact despite change the state via a context.
What am I doing wrong?
console.log(myList) prints (0) depite clicking the buttons that aught to trigger a new entry based on myEnum.
Thanks!
Set is different than Array and it only contains distinct elements.
The Set object lets you store unique values of any type, whether primitive values or object references, according to MDN
Therefore, your code already worked. If you console log as myList.size will return 2 and [...myList] will return an array ["a", "b"] with an updated values and you can iterate over it to create necessary jsx and etc.
console.log([...myList]); // ["a", "b"]
console.log(myList.size); // 2
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.
So I have this API data, which is being stored in redux store. The first element in the redux state data is then used in useEffect to set a local state used in the initial render. Now in a button click handler function, I'm trying to use that local state to filter out the data from redux state. Then the first element from filtered data should be pushed to the local state.
I'll explain the code here. Suppose I'm getting the API data from redux store like this:
const categoriesState = useSelector( state => state.getCats );
const { categories } = categoriesState;
Then in useEffect I use the first element from categories to be stored in a local state:
const [ catItems, setCatItems ] = useState( [] );
useEffect(() => {
if ( categories && categories.length !== 0 ) {
setCatItems( [ categories[ 0 ] ] );
}
}, [] );
catItems is then used to render something. But there is also a button that, upon clicking, will add a new category to catItems from categories state but leave out the item which is already in it.
Here's the click handler:
const handleAddCat = ( e ) => {
if ( categories && categories.length !== 0 ) {
const catCopy = [ ...catItems ];
const filterCategories = categories.filter( item => {
for ( let el of catCopy ) {
return item.category !== el.category;
}
});
catCopy.push( filterCategories[ 0 ] );
setCatItems( catCopy );
}
}
On the first click, the filter works perfectly. But on the second button click, I get the same data. For example, if the local state's initial data is 1, then on the first button click, I get both 1 and 2. But on second button click I get 1, 2 and 2. 2 is being added on subsequent clicks. Whereas, 2 should be filtered out. Then 3, then 4 and so on.
What am I doing wrong here?
You can just find the mismatched category instead of ( creating the filterCategories and then picking the first one ).
Here is the implementation.
const handleAddCat = ( e ) => {
if ( categories && categories.length !== 0 ) {
const newCategory = categories.find(category => !cat.includes(category));
if(newCategory) setCatItems((prevItems) => [...prevItems, newCategory]);
}
}
for loop does not return anything from filter. In your case you can do something like this:
const filterCategories = categories.filter((item) => {
return Boolean(catCopy.find((el) => item.category !== el.category));
});
I have a form that consists of cards where the user needs to select the derised ones. This form uses the useFormik hook and yup for validation. The code looks like so:
Declaration of formik in a form.tsx
export const validationSchema = object().shape({
name: string().required(),
networks_ids: array().min(1),
players_ids: array().min(1),
})
export const useForm = () => {
const formik = useFormik<IFormikValues>({
initialValues: {
name: '',
networks_ids: [],
players_ids: []
},
validationSchema,
Then the parent component looks like this (different file):
{networks?.map((wspace, index) => (
<div key={index}>
<NetworkSelectCard
formik={formik}
name={`networks_ids[${index}]`}
network={wspace!}
value={wspace?._id}
/>
</div>
))}
Child component
const NetworkSelectCard: FC<NetworkSelectCardProps> = ({
formik,
name,
network,
value,
}) => {
return (
<SelectCardFormik {...{ formik, name, network, value }}>
**Some JSX in here***
</SelectCardFormik>
Another child component again in a different file
export const SelectCardFormik: FC<SelectCardFormikProps> = ({
formik,
...props
}) => {
const meta = formik?.getFieldMeta(props.name!)
return (
<SelectCard
{...props}
checked={meta?.value?.length > 0}
/>
)
}
And finally:
const SelectCard: FC<SelectCardProps> = (props) => {
const { type, value, disabled, name, children, checked, onChange } = props
const inputRef = useRef<HTMLInputElement>(null)
return (
***More out of context JSX in here***
<input
ref={inputRef}
name={name}
type={type || 'checkbox'}
checked={checked}
value={value}
onChange={onChange}
readOnly
/>
The problem now is that if console.log(formik.values) I see that it is doing this:
{
"name": "My dope name",
"networks_ids": [
[
"5f33eb0873c9ef232a58cf53"
],
[
"5f33eb0873c9ef232a58cf54"
]
],
"players_ids": [],
// Should be and array of strings and not an array of arrays
I can't really understand how this is hapenning, maybe I'm missing something because it is the first time I'm working with formik.
If no cards are selected the Next step button needs to be disabled. So I could also try to flatten the array on onSubmit but for that I need to adjust my schemaValidation, which I'm also having a hard time to understand how could I validate if an array is empty or it only has arrays inside of it that are empty to. Because when unselecting the card, it only removes the id inside of the array but keeps an empty array, like the following:
"networks_ids": [
[],
[
"5f33eafa73c9ef232a58cf52"
]
],
I'm sorry for the LONG LONG question but I already waisted to much hours arounds this I can't find a solution anywhere.
Thank you
Its very tough to understand whatever you have explained. However if you want a solution to use your networks_ids then use
networks_ids.flat() // returns array of string
I have a functional component using Hooks:
function Component(props) {
const [ items, setItems ] = useState([]);
// In a callback Hook to prevent unnecessary re-renders
const handleFetchItems = useCallback(() => {
fetchItemsFromApi().then(setItems);
}, []);
// Fetch items on mount
useEffect(() => {
handleFetchItems();
}, []);
// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffect(() => {
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [ items, props.itemId ])
// Clicking the button should NOT log anything to console
return (
<Button onClick={handleFetchItems}>Fetch items</Button>
);
}
The component fetches some items on mount and saves them to state.
The component receives an itemId prop (from React Router).
Whenever the props.itemId changes, I want this to trigger an effect, in this case logging it to console.
The problem is that, since the effect is also dependent on items, the effect will also run whenever items changes, for instance when the items are re-fetched by pressing the button.
This can be fixed by storing the previous props.itemId in a separate state variable and comparing the two, but this seems like a hack and adds boilerplate. Using Component classes this is solved by comparing current and previous props in componentDidUpdate, but this is not possible using functional components, which is a requirement for using Hooks.
What is the best way to trigger an effect dependent on multiple parameters, only when one of the parameters change?
PS. Hooks are kind of a new thing, and I think we all are trying our best to figure out how to properly work with them, so if my way of thinking about this seems wrong or awkward to you, please point it out.
The React Team says that the best way to get prev values is to use useRef: https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
function Component(props) {
const [ items, setItems ] = useState([]);
const prevItemIdRef = useRef();
useEffect(() => {
prevItemIdRef.current = props.itemId;
});
const prevItemId = prevItemIdRef.current;
// In a callback Hook to prevent unnecessary re-renders
const handleFetchItems = useCallback(() => {
fetchItemsFromApi().then(setItems);
}, []);
// Fetch items on mount
useEffect(() => {
handleFetchItems();
}, []);
// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffect(() => {
if(prevItemId !== props.itemId) {
console.log('diff itemId');
}
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [ items, props.itemId ])
// Clicking the button should NOT log anything to console
return (
<Button onClick={handleFetchItems}>Fetch items</Button>
);
}
I think that this could help in your case.
Note: if you don't need the previous value, another approach is to write one useEffect more for props.itemId
React.useEffect(() => {
console.log('track changes for itemId');
}, [props.itemId]);
An easy way out is to write a custom hook to help us with that
// Desired hook
function useCompare (val) {
const prevVal = usePrevious(val)
return prevVal !== val
}
// Helper hook
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
and then use it in useEffect
function Component(props) {
const hasItemIdChanged = useCompare(props.itemId);
useEffect(() => {
console.log('item id changed');
}, [hasItemIdChanged])
return <></>
}
⚠️ NOTE: This answer is currently incorrect and could lead to unexpected bugs / side-effects. The useCallback variable would need to be a dependency of the useEffect hook, therefore leading to the same problem as OP was facing.
I will address it asap
Recently ran into this on a project, and our solution was to move the contents of the useEffect to a callback (memoized in this case) - and adjust the dependencies of both. With your provided code it looks something like this:
function Component(props) {
const [ items, setItems ] = useState([]);
const onItemIdChange = useCallback(() => {
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [items, props.itemId]);
// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffect(onItemIdChange, [ props.itemId ]);
// Clicking the button should NOT log anything to console
return (
<Button onClick={handleFetchItems}>Fetch items</Button>
);
}
So the useEffect just has the ID prop as its dependency, and the callback both the items and the ID.
In fact you could remove the ID dependency from the callback and pass it as a parameter to the onItemIdChange callback:
const onItemIdChange = useCallback((id) => {
if (items) {
const item = items.find(item => item.id === id);
console.log("Item changed to " item.name);
}
}, [items]);
useEffect(() => {
onItemIdChange(props.itemId)
}, [ props.itemId ])
I am a react hooks beginner so this might not be right but I ended up defining a custom hook for this sort of scenario:
const useEffectWhen = (effect, deps, whenDeps) => {
const whenRef = useRef(whenDeps || []);
const initial = whenRef.current === whenDeps;
const whenDepsChanged = initial || !whenRef.current.every((w, i) => w === whenDeps[i]);
whenRef.current = whenDeps;
const nullDeps = deps.map(() => null);
return useEffect(
whenDepsChanged ? effect : () => {},
whenDepsChanged ? deps : nullDeps
);
}
It watches a second array of dependencies (which can be fewer than the useEffect dependencies) for changes & produces the original useEffect if any of these change.
Here's how you could use (and reuse) it in your example instead of useEffect:
// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffectWhen(() => {
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [ items, props.itemId ], [props.itemId])
Here's a simplified example of it in action, useEffectWhen will only show up in the console when the id changes, as opposed to useEffect which logs when items or id changes.
This will work without any eslint warnings, but that's mostly because it confuses the eslint rule for exhaustive-deps! You can include useEffectWhen in the eslint rule if you want to make sure you have the deps you need. You'll need this in your package.json:
"eslintConfig": {
"extends": "react-app",
"rules": {
"react-hooks/exhaustive-deps": [
"warn",
{
"additionalHooks": "useEffectWhen"
}
]
}
},
and optionally this in your .env file for react-scripts to pick it up:
EXTEND_ESLINT=true
2022 answer
I know that this is an old question, but it's still worth an answer. Effects have received a lot of attention this year, and it's now clearer what you should / shouldn't do with effects than it was back then.
The new React docs, which are still in beta, cover the topic in length.
So, before answering the 'how', you must first answer the 'why', because there aren't actually many genuine use cases for running an effect only when certain dependencies change.
You might not need an effect
'You might not need an effect' is actually the title of the page that demonstrates that you might be using an effect for the wrong reasons. And the example you gave us is actually discussed in 'adjusting some state when a prop changes'
Your code can be simply rewritten without the second effect:
function Component(props) {
const [ items, setItems ] = useState([]);
// In a callback Hook to prevent unnecessary re-renders
const handleFetchItems = useCallback(() => {
fetchItemsFromApi().then(setItems);
}, []);
// Fetch items on mount
useEffect(() => {
handleFetchItems();
}, [handleFetchItems]);
// Find the item that matches in the list
const item = items.find(item => item.id === props.itemId);
return (
<Button onClick={handleFetchItems}>Fetch items</Button>
);
}
Indeed, you don't need an effect just to identify the item from the list. React already re-renders when items changes, so item can be re-computed in the component directly.
Note: if you really want to log to the console when the item changes, you can still add this code to your component:
useEffect(() => {
console.log("Item changed to ", item?.name);
}, [item])
Note how item is the only dependency of the effect, so it only runs when item changes. But this is not a good use case for an effect again.
Instead, if you want to do something in response to an event that occurs in your application, you should do this 👇
Separating Events from Effects
And yes, 'Separating Events from Effects' is also the title of a page in the new React docs! In the example you gave us, you want to write to the console whenever item changes. But I'm not naive and I know you might want to do more than this in a practice.
If you want to do something when something else changes, then you can use the new useEvent hook to create an event handler for it (or at least, use its polyfill since it hasn't been released yet). The event in question here is the itemId prop changing.
You can then change your second effect to:
// Handle a change of item ID
const onItemIdChange = useEvent((itemId) => {
const item = items?.find(item => item.id === itemId);
console.log("Item changed to " item.name);
});
// Trigger onItemIdChange when item ID changes
useEffect(() => {
onItemIdChange(props.itemId);
}, [props.itemId]);
The advantage of this method is that useEvent returns a stable function, so you don't need to add it as a dependency to your effect (it never changes). Also, the function you pass to useEvent has no dependency array: it can access all the properties, states or variables inside your component and it is always fresh (no stale values).
I still think that based on the example you gave us, you don't really need an effect. But for other genuine cases, at least you know how to extract events from your effects now and thus you can avoid hacking the dependency array.
Based on the previous answers here and inspired by react-use's useCustomCompareEffect implementation, I went on writing the useGranularEffect hook to solve a similar issue:
// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useGranularEffect(() => {
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [ items ], [ props.itemId ])
implemented as (TypeScript):
export const useGranularEffect = (
effect: EffectCallback,
primaryDeps: DependencyList,
secondaryDeps: DependencyList
) => {
const ref = useRef<DependencyList>();
if (!ref.current || !primaryDeps.every((w, i) => Object.is(w, ref.current[i]))) {
ref.current = [...primaryDeps, ...secondaryDeps];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
return useEffect(effect, ref.current);
};
Try it on codesandbox
The signature of useGranularEffect is the same as useEffect, except that the list of dependencies has been split into two:
primary dependencies: the effect only runs when these dependencies change
secondary dependencies: all the other dependencies used in the effect
In my opinion, it makes the case of running the effect only when certain dependencies change easier to read.
Notes:
Unfortunately, there is not linting rule to help you ensure that the two arrays of dependencies are exhaustive, so it is your responsibility to make sure you're not missing any
It is safe to ignore the linting warning inside the implementation of useGranularEffect because effect is not an actual dependency (it's the effect function itself) and ref.current contains the list of all dependencies (primary + secondary, which the linter cannot guess)
I'm using Object.is to compare dependencies so that it's consistent with the behaviour of useEffect, but feel free to use your own compare function or, better, to add a comparer as argument
UPDATE: useGranularEffect has now be published into the granular-hooks package. So just:
npm install granular-hooks
then
import { useGranularEffect } from 'granular-hooks'
I just tried this myself and it seems to me that you don't need to put things in the useEffect dependency list in order to have their updated versions. Meaning you can just solely put in props.itemId and still use items within the effect.
I created a snippet here to attempt to prove/illustrate this. Let me know if something is wrong.
const Child = React.memo(props => {
const [items, setItems] = React.useState([]);
const fetchItems = () => {
setTimeout(() => {
setItems((old) => {
const newItems = [];
for (let i = 0; i < old.length + 1; i++) {
newItems.push(i);
}
return newItems;
})
}, 1000);
}
React.useEffect(() => {
console.log('OLD (logs on both buttons) id:', props.id, 'items:', items.length);
}, [props.id, items]);
React.useEffect(() => {
console.log('NEW (logs on only the red button) id:', props.id, 'items:', items.length);
}, [props.id]);
return (
<div
onClick={fetchItems}
style={{
width: "200px",
height: "100px",
marginTop: "12px",
backgroundColor: 'orange',
textAlign: "center"
}}
>
Click me to add a new item!
</div>
);
});
const Example = () => {
const [id, setId] = React.useState(0);
const updateId = React.useCallback(() => {
setId(old => old + 1);
}, []);
return (
<div style={{ display: "flex", flexDirection: "row" }}>
<Child
id={id}
/>
<div
onClick={updateId}
style={{
width: "200px",
height: "100px",
marginTop: "12px",
backgroundColor: 'red',
textAlign: "center"
}}
>Click me to update the id</div>
</div>
);
};
ReactDOM.render(<Example />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id='root' style='width: 100%; height: 100%'>
</div>
From the example provided, your effect does not depend on items and itemId, but one of the items from the collection.
Yes, you need items and itemId to get that item, but it does not mean you have to specify them in the dependency array.
To make sure it is executed only when the target item changes, you should pass that item to dependency array using the same lookup logic.
useEffect(() => {
if (items) {
const item = items.find(item => item.id === props.itemId);
console.log("Item changed to " item.name);
}
}, [ items.find(item => item.id === props.itemId) ])