How to debug "Warning: Maximum update depth exceeded" in React [duplicate] - reactjs

Is there an easy way to determine which variable in a useEffect's dependency array triggers a function re-fire?
Simply logging out each variable can be misleading, if a is a function and b is an object they may appear the same when logged but actually be different and causing useEffect fires.
For example:
React.useEffect(() => {
// which variable triggered this re-fire?
console.log('---useEffect---')
}, [a, b, c, d])
My current method has been removing dependency variables one by one until I notice the behavior that causes excessive useEffect calls, but there must be a better way to narrow this down.

I ended up taking a little bit from various answers to make my own hook for this. I wanted the ability to just drop something in place of useEffect for quickly debugging what dependency was triggering useEffect.
const usePrevious = (value, initialValue) => {
const ref = useRef(initialValue);
useEffect(() => {
ref.current = value;
});
return ref.current;
};
const useEffectDebugger = (effectHook, dependencies, dependencyNames = []) => {
const previousDeps = usePrevious(dependencies, []);
const changedDeps = dependencies.reduce((accum, dependency, index) => {
if (dependency !== previousDeps[index]) {
const keyName = dependencyNames[index] || index;
return {
...accum,
[keyName]: {
before: previousDeps[index],
after: dependency
}
};
}
return accum;
}, {});
if (Object.keys(changedDeps).length) {
console.log('[use-effect-debugger] ', changedDeps);
}
useEffect(effectHook, dependencies);
};
Below are two examples. For each example, I assume that dep2 changes from 'foo' to 'bar'. Example 1 shows the output without passing dependencyNames and Example 2 shows an example with dependencyNames.
Example 1
Before:
useEffect(() => {
// useEffect code here...
}, [dep1, dep2])
After:
useEffectDebugger(() => {
// useEffect code here...
}, [dep1, dep2])
Console output:
{
1: {
before: 'foo',
after: 'bar'
}
}
The object key '1' represents the index of the dependency that changed. Here, dep2 changed as it is the 2nd item in the dependency, or index 1.
Example 2
Before:
useEffect(() => {
// useEffect code here...
}, [dep1, dep2])
After:
useEffectDebugger(() => {
// useEffect code here...
}, [dep1, dep2], ['dep1', 'dep2'])
Console output:
{
dep2: {
before: 'foo',
after: 'bar'
}
}

#simbathesailor/use-what-changed works like a charm!
Install with npm/yarn and --dev or --no-save
Add import:
import { useWhatChanged } from '#simbathesailor/use-what-changed';
Call it:
// (guarantee useEffect deps are in sync with useWhatChanged)
let deps = [a, b, c, d]
useWhatChanged(deps, 'a, b, c, d');
useEffect(() => {
// your effect
}, deps);
Creates this nice chart in the console:
There are two common culprits:
Some Object being pass in like this:
// Being used like:
export function App() {
return <MyComponent fetchOptions={{
urlThing: '/foo',
headerThing: 'FOO-BAR'
})
}
export const MyComponent = ({fetchOptions}) => {
const [someData, setSomeData] = useState()
useEffect(() => {
window.fetch(fetchOptions).then((data) => {
setSomeData(data)
})
}, [fetchOptions])
return <div>hello {someData.firstName}</div>
}
The fix in the object case, if you can, break-out a static object outside the component render:
const fetchSomeDataOptions = {
urlThing: '/foo',
headerThing: 'FOO-BAR'
}
export function App() {
return <MyComponent fetchOptions={fetchSomeDataOptions} />
}
You can also wrap in useMemo:
export function App() {
return <MyComponent fetchOptions={
useMemo(
() => {
return {
urlThing: '/foo',
headerThing: 'FOO-BAR',
variableThing: hash(someTimestamp)
}
},
[hash, someTimestamp]
)
} />
}
The same concept applies to functions to an extent, except you can end up with stale closures.

UPDATE
After a little real-world use, I so far like the following solution which borrows some aspects of Retsam's solution:
const compareInputs = (inputKeys, oldInputs, newInputs) => {
inputKeys.forEach(key => {
const oldInput = oldInputs[key];
const newInput = newInputs[key];
if (oldInput !== newInput) {
console.log("change detected", key, "old:", oldInput, "new:", newInput);
}
});
};
const useDependenciesDebugger = inputs => {
const oldInputsRef = useRef(inputs);
const inputValuesArray = Object.values(inputs);
const inputKeysArray = Object.keys(inputs);
useMemo(() => {
const oldInputs = oldInputsRef.current;
compareInputs(inputKeysArray, oldInputs, inputs);
oldInputsRef.current = inputs;
}, inputValuesArray); // eslint-disable-line react-hooks/exhaustive-deps
};
This can then be used by copying a dependency array literal and just changing it to be an object literal:
useDependenciesDebugger({ state1, state2 });
This allows the logging to know the names of the variables without any separate parameter for that purpose.

As far as I know, there's no really easy way to do this out of the box, but you could drop in a custom hook that keeps track of its dependencies and logs which one changed:
// Same arguments as useEffect, but with an optional string for logging purposes
const useEffectDebugger = (func, inputs, prefix = "useEffect") => {
// Using a ref to hold the inputs from the previous run (or same run for initial run
const oldInputsRef = useRef(inputs);
useEffect(() => {
// Get the old inputs
const oldInputs = oldInputsRef.current;
// Compare the old inputs to the current inputs
compareInputs(oldInputs, inputs, prefix)
// Save the current inputs
oldInputsRef.current = inputs;
// Execute wrapped effect
func()
}, inputs);
};
The compareInputs bit could look something like this:
const compareInputs = (oldInputs, newInputs, prefix) => {
// Edge-case: different array lengths
if(oldInputs.length !== newInputs.length) {
// Not helpful to compare item by item, so just output the whole array
console.log(`${prefix} - Inputs have a different length`, oldInputs, newInputs)
console.log("Old inputs:", oldInputs)
console.log("New inputs:", newInputs)
return;
}
// Compare individual items
oldInputs.forEach((oldInput, index) => {
const newInput = newInputs[index];
if(oldInput !== newInput) {
console.log(`${prefix} - The input changed in position ${index}`);
console.log("Old value:", oldInput)
console.log("New value:", newInput)
}
})
}
You could use this like this:
useEffectDebugger(() => {
// which variable triggered this re-fire?
console.log('---useEffect---')
}, [a, b, c, d], 'Effect Name')
And you would get output like:
Effect Name - The input changed in position 2
Old value: "Previous value"
New value: "New value"

There’s another stack overflow thread stating you can use useRef to see a previous value.
https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state

The React beta docs suggest these steps:
Log your dependency array with console.log:
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
console.log([todos, tab]);
Right-click on the arrays from different re-renders in the console and select “Store as a global variable” for both of them. It may be important not to compare two sequential ones if you are in strict mode, I'm not sure.
Compare each of the dependencies:
Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays?

This question was answered with several good and working answers, but I just didn't like the DX of any of them.
so I wrote a library which logs the dependencies that changed in the easiest way to use + added a function to log a deep comparison between 2 objects, so you can know what exactly changed inside your object.
I called it: react-what-changed
The readme has all of the examples you need.
The usage is very straight forward:
npm install react-what-changed --save-dev
import { reactWhatChanged as RWC } from 'react-what-changed';
function MyComponent(props) {
useEffect(() => {
someLogic();
}, RWC([somePrimitive, someArray, someObject]));
}
In this package you will also find 2 useful functions for printing deep comparison (diffs only) between objects. for example:
import { reactWhatDiff as RWD } from 'react-what-changed';
function MyComponent(props) {
useEffect(() => {
someLogic();
}, [somePrimitive, someArray, someObject]);
RWD(someArray);
}

Related

Cannot update a component while rendering a different Component - ReactJS

I know lots of developers had similar kinds of issues in the past like this. I went through most of them, but couldn't crack the issue.
I am trying to update the cart Context counter value. Following is the code(store/userCartContext.js file)
import React, { createContext, useState } from "react";
const UserCartContext = createContext({
userCartCTX: [],
userCartAddCTX: () => {},
userCartLength: 0
});
export function UserCartContextProvider(props) {
const [userCartStore, setUserCartStore] = useState([]);
const addCartProduct = (value) => {
setUserCartStore((prevState) => {
return [...prevState, value];
});
};
const userCartCounterUpdate = (id, value) => {
console.log("hello dolly");
// setTimeout(() => {
setUserCartStore((prevState) => {
return prevState.map((item) => {
if (item.id === id) {
return { ...item, productCount: value };
}
return item;
});
});
// }, 50);
};
const context = {
userCartCTX: userCartStore,
userCartAddCTX: addCartProduct,
userCartLength: userCartStore.length,
userCartCounterUpdateCTX: userCartCounterUpdate
};
return (
<UserCartContext.Provider value={context}>
{props.children}
</UserCartContext.Provider>
);
}
export default UserCartContext;
Here I have commented out the setTimeout function. If I use setTimeout, it works perfectly. But I am not sure whether it's the correct way.
In cartItemEach.js file I use the following code to update the context
const counterChangeHandler = (value) => {
let counterVal = value;
userCartBlockCTX.userCartCounterUpdateCTX(props.details.id, counterVal);
};
CodeSandBox Link: https://codesandbox.io/s/react-learnable-one-1z5td
Issue happens when I update the counter inside the CART popup. If you update the counter only once, there won't be any error. But when you change the counter more than once this error pops up inside the console. Even though this error arises, it's not affecting the overall code. The updated counter value gets stored inside the state in Context.
TIL that you cannot call a setState function from within a function passed into another setState function. Within a function passed into a setState function, you should just focus on changing that state. You can use useEffect to cause that state change to trigger another state change.
Here is one way to rewrite the Counter class to avoid the warning you're getting:
const decrementHandler = () => {
setNumber((prevState) => {
if (prevState === 0) {
return 0;
}
return prevState - 1;
});
};
const incrementHandler = () => {
setNumber((prevState) => {
return prevState + 1;
});
};
useEffect(() => {
props.onCounterChange(props.currentCounterVal);
}, [props.currentCounterVal]);
// or [props.onCounterChange, props.currentCounterVal] if onCounterChange can change
It's unclear to me whether the useEffect needs to be inside the Counter class though; you could potentially move the useEffect outside to the parent, given that both the current value and callback are provided by the parent. But that's up to you and exactly what you're trying to accomplish.

Why do I get undefined value from async function?

I have been using Google firestore as a database for my projet.
In the collection "paths", I store all the paths I have in my app, which are composed of 2 fields : name, and coordinates (which is an array of objects with coordinates of points).
Anyway, i created a utility file in utils/firebase.js
In the file, i have this function which gets all the paths in my collection and return an array of all documents found :
export const fetchPaths = () => {
let pathsRef = db.collection('paths');
let pathsArray = []
pathsRef.get().then((response) => {
response.docs.forEach(path => {
const {nom, coordonnees } = path.data();
pathsArray.push({ nom: nom, coordonnees: coordonnees})
})
console.log(pathsArray)
return pathsArray;
});
};
In my react component, What i want to do is to load this function in useEffect to have all the data, and then display them. Here is the code I use :
import { addPath, fetchPaths } from './Utils/firebase';
//rest of the code
useEffect(() => {
let paths = fetchPaths()
setLoadedPaths(paths);
}, [loadedPaths])
//.......
The issue here is if I console log pathsArray in the function it's correct, but it never gets to the state.
When i console log paths in the component file, i get undefined.
I am quite new with react, i tried different things with await/async, etc. But I don't know what i am doing wrong here / what i misunderstand.
I know that because of my dependency, i would be supposed to have an infinite loop, but it's not even happening
Thank you for your help
Have a nice day
fetchPaths does not return any result. It should be:
export const fetchPaths = () => {
let pathsRef = db.collection('paths');
let pathsArray = []
return pathsRef.get().then((response) => {
response.docs.forEach(path => {
const {nom, coordonnees } = path.data();
pathsArray.push({ nom: nom, coordonnees: coordonnees})
})
console.log(pathsArray)
return pathsArray;
});
};
note the return statement.
Since the fetchPaths returns a promise, in the effect it should be like following:
useEffect(() => {
fetchPaths().then(paths =>
setLoadedPaths(paths));
}, [loadedPaths])

How to let react consider returned value of custom hook as stable identity?

In the react document, we can find:
Note
React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.
Sometimes, custom hook can also guarantee the returned function identity is stable, is it possible to let react know it?
Added after discussing with Jayce444:
If react dose not consider the returned value of custom hook as stable identity but we omit it from the dependency list of other hooks, npm will report warnings
Considering you want to silence react-hooks/exhaustive-deps eslint rule for specific names, I have written a patch for that rule, which allows me to use the following config:
'react-hooks/exhaustive-deps': [
'warn',
{
stableHooksPattern: 'useDispatch|useSharedValue|useErrorDropdown',
ignoredDepsPattern: '^navigation|navigate|popToTop|pop$',
},
],
which essentially results in the following hook call being "clean":
const navigation = useNavigation();
useEffect(() => {
navigation.navigate('Voila');
}, []);
Here's the source code of my patch for eslint-plugin-react-hooks#4.3.0:
diff --git a/node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js b/node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js
index 29fb123..b22bc3f 100644
--- a/node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js
+++ b/node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js
## -735,6 +735,12 ## var ExhaustiveDeps = {
},
enableDangerousAutofixThisMayCauseInfiniteLoops: {
type: 'boolean'
+ },
+ stableHooksPattern: {
+ type: 'string'
+ },
+ ignoredDepsPattern: {
+ type: 'string'
}
}
}]
## -743,9 +749,13 ## var ExhaustiveDeps = {
// Parse the `additionalHooks` regex.
var additionalHooks = context.options && context.options[0] && context.options[0].additionalHooks ? new RegExp(context.options[0].additionalHooks) : undefined;
var enableDangerousAutofixThisMayCauseInfiniteLoops = context.options && context.options[0] && context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops || false;
+ var stableHooksPattern = context.options && context.options[0] && context.options[0].stableHooksPattern ? new RegExp(context.options[0].stableHooksPattern) : undefined;
+ var ignoredDepsPattern = context.options && context.options[0] && context.options[0].ignoredDepsPattern ? new RegExp(context.options[0].ignoredDepsPattern) : undefined;
var options = {
additionalHooks: additionalHooks,
- enableDangerousAutofixThisMayCauseInfiniteLoops: enableDangerousAutofixThisMayCauseInfiniteLoops
+ enableDangerousAutofixThisMayCauseInfiniteLoops: enableDangerousAutofixThisMayCauseInfiniteLoops,
+ stableHooksPattern: stableHooksPattern,
+ ignoredDepsPattern: ignoredDepsPattern
};
function reportProblem(problem) {
## -904,7 +914,9 ## var ExhaustiveDeps = {
var _callee = callee,
name = _callee.name;
- if (name === 'useRef' && id.type === 'Identifier') {
+ if (options.stableHooksPattern && options.stableHooksPattern.test(name)) {
+ return true;
+ } else if (name === 'useRef' && id.type === 'Identifier') {
// useRef() return value is stable.
return true;
} else if (name === 'useState' || name === 'useReducer') {
## -1098,7 +1110,7 ## var ExhaustiveDeps = {
if (!dependencies.has(dependency)) {
var resolved = reference.resolved;
- var isStable = memoizedIsStablecKnownHookValue(resolved) || memoizedIsFunctionWithoutCapturedValues(resolved);
+ var isStable = memoizedIsStablecKnownHookValue(resolved) || memoizedIsFunctionWithoutCapturedValues(resolved) || (options.ignoredDepsPattern && options.ignoredDepsPattern.test(resolved.name));
dependencies.set(dependency, {
isStable: isStable,
references: [reference]
The filename for the patch is eslint-plugin-react-hooks+4.3.0.patch, and you should place it into patches directory at the root of your repository.
Disclaimer: this will only work globally. So if you want to declare a return value as "stable", you will have to list it in the eslint config, and it will take effect globally in your project.
If you want to be able to "mark" return values as stable, you will need to further modify that eslint plugin to make it work. Unfortunately, I don't have time for that.
In your case you don't really want to just hide that warning for custom code. React does it for setState functions because it's referring to something inside its own library. As commenters have mentioned, you can disable the linting rule for that specific line, but it's probably better just to include this dependency.
When you write code, you generally want it to be loosely coupled from its context, to make no assumptions about where exactly it's being used. While in your current use case, you know the function from the hook isn't changing, that could change in the future. Consider this example:
const useCustomHook = () => {
const calculate = useCallback((number) => {
// Do stuff here
}, []);
return ({ calculate });
};
const MyComponent = () => {
const [number, setNumber] = useState(0);
const { calculate } = useCustomHook();
useEffect(() => {
calculate(number);
}, [number]);
// rest of the component
};
Simple example, you have a memoized calculate function returned from the custom hook, and a number in your component's state. When the number changes, recalculate. And you can see, we've left calculate out of the useEffect dependencies, as you're wanting to do in your use case.
But let's say this changes, and we replace the custom hook with this one:
const useCustomHook = () => {
const someValue = useContext(someRandomContext);
const calculateOne = (number) => {/* some code */};
const calculateTwo = (number) => {/* some code */};
const calculate = useCallback(someValue ? calculateOne : calculateTwo, [someValue]);
return ({ calculate });
};
Now, when the context value changes, the calculate function changes too. However, if you don't have that dependency in your component's useEffect, the calculation won't actually get fired, and you'll now have a stale/incorrect value in your state.
While technically having that dependency may be redundant for you at the moment, if you program defensively you'll avoid bugs like that which can be a pain in the ass to track down. Especially because of the chains of dependencies you can get when using custom hooks, and hooks that use other hooks, etc.. It's literally a handful of extra characters in your dependency array, best to just add it and avoid a potential headache down the road.
#Jayce444, Thanks very much, I know your option.
My goal is to declare the hook like useState very much, ThisHook = useState + immer(https://github.com/immerjs/immer)
Here is my custom hook
import produce, { Draft } from "immer";
import { useCallback, useState } from "react";
export type DraftMutation<T> = (draft: Draft<T>) => void;
export function useImmerState<T>(
initialValue: T
): [T, (mutation: DraftMutation<T>) => void] {
const [value, setValue] = useState(initialValue);
const setValueByImmer = useCallback((mutation: DraftMutation<T>) => {
setValue(oldValue => {
return produce(oldValue, draft => mutation(draft));
});
}, []);
return [value, setValueByImmer];
}
Then, let's discuss how to use it.
Setp 1, define a simple type, like this:
interface Point {
readonly x: number;
readonly y: number;
}
Setp 2, use my custom hook is functional component
const [point, setPoint] = useImmerState<Point>({x: 0, y: 0});
const onButtonClick = useCallback(() => {
setPoint(draft => {
draft.x++;
draft.y++;
});
}, []); //Need not add 'setPoint' into the dependency list, and no eslint warining should appear
Your demo is very greate, but this hook can guarantee it have no problems. This hook looks like useState very much, that's why I want this future. React should support it if the developer knowns what he/she is doing.

Why doesn't a useEffect hook trigger with an object in the dependency array?

I'm noticing something really strange while working with hooks, I've got the following:
import React, { useState, useEffect } from "react";
const [dependency1, setDependency1] = useState({});
const [dependency2, setDependency2] = useState([]);
useEffect(() => {
console.log("dependency 1 got an update");
}, [dependency1]);
useEffect(() => {
console.log("dependency 2 got an update");
}, [dependency2]);
setInterval(() => {
setDependency1(prevDep1 => {
const _key = "test_" + Math.random().toString();
if (prevDep1[_key] === undefined) prevDep1[_key] = [];
else prevDep1[key].push("foo");
return prevDep1;
})
setDependency2(prevDep2 => [...prevDep2, Math.random()]);
}, 1000);
for some reason only the useEffect with dependency2 (the array where items get added) triggers, the one with dependency1 (the object where keys get added) doesn't trigger..
Why is this happening, and how can I make it work?
setInterval(() => {
setDependency1(prevDep1 => {
const _key = "test_" + Math.random().toString();
return {...prevDep1, [_key]: [...(prevDep1[_key] || []), 'foo'] }
})
setDependency2(prevDep2 => [...prevDep2, Math.random()]);
}, 1000);
State should be updated in an immutable way.
React will only check for reference equality when deciding a dependency changed, so if the old and new values pass a === check, it considers it unchanged.
In your first dependency you simply added a key to the existing object, thus not changing the actual object. The second dependency actually gets replaced altogether when spreading the old values into a new array.
You're returning an assignment statement here:
setDependency1(prevDep1 => prevDep1["test_" + Math.random().toString()] = ["foo"]);
You should return an object. Maybe something like:
setDependency1(prevDep1 => ({ ...prevDep1, ["test_" + Math.random().toString()]: ["foo"] }));

Passing array to useEffect dependency list

There's some data coming from long polling every 5 seconds and I would like my component to dispatch an action every time one item of an array (or the array length itself) changes.
How do I prevent useEffect from getting into infinity loop when passing an array as dependency to useEffect but still manage to dispatch some action if any value changes?
useEffect(() => {
console.log(outcomes)
}, [outcomes])
where outcomes is an array of IDs, like [123, 234, 3212]. The items in array might be replaced or deleted, so the total length of the array might - but don't have to - stay the same, so passing outcomes.length as dependency is not the case.
outcomes comes from reselect's custom selector:
const getOutcomes = createSelector(
someData,
data => data.map(({ outcomeId }) => outcomeId)
)
You can pass JSON.stringify(outcomes) as the dependency list:
Read more here
useEffect(() => {
console.log(outcomes)
}, [JSON.stringify(outcomes)])
Using JSON.stringify() or any deep comparison methods may be inefficient, if you know ahead the shape of the object, you can write your own effect hook that triggers the callback based on the result of your custom equality function.
useEffect works by checking if each value in the dependency array is the same instance with the one in the previous render and executes the callback if one of them is not. So we just need to keep the instance of the data we're interested in using useRef and only assign a new one if the custom equality check return false to trigger the effect.
function arrayEqual(a1: any[], a2: any[]) {
if (a1.length !== a2.length) return false;
for (let i = 0; i < a1.length; i++) {
if (a1[i] !== a2[i]) {
return false;
}
}
return true;
}
type MaybeCleanUpFn = void | (() => void);
function useNumberArrayEffect(cb: () => MaybeCleanUpFn, deps: number[]) {
const ref = useRef<number[]>(deps);
if (!arrayEqual(deps, ref.current)) {
ref.current = deps;
}
useEffect(cb, [ref.current]);
}
Usage
function Child({ arr }: { arr: number[] }) {
useNumberArrayEffect(() => {
console.log("run effect", JSON.stringify(arr));
}, arr);
return <pre>{JSON.stringify(arr)}</pre>;
}
Taking one step further, we can also reuse the hook by creating an effect hook that accepts a custom equality function.
type MaybeCleanUpFn = void | (() => void);
type EqualityFn = (a: DependencyList, b: DependencyList) => boolean;
function useCustomEffect(
cb: () => MaybeCleanUpFn,
deps: DependencyList,
equal?: EqualityFn
) {
const ref = useRef<DependencyList>(deps);
if (!equal || !equal(deps, ref.current)) {
ref.current = deps;
}
useEffect(cb, [ref.current]);
}
Usage
useCustomEffect(
() => {
console.log("run custom effect", JSON.stringify(arr));
},
[arr],
(a, b) => arrayEqual(a[0], b[0])
);
Live Demo
Another ES6 option would be to use template literals to make it a string. Similar to JSON.stringify(), except the result won't be wrapped in []
useEffect(() => {
console.log(outcomes)
}, [`${outcomes}`])
Another option, if the array size doesn't change, would be to spread it in:
useEffect(() => {
console.log(outcomes)
}, [ ...outcomes ])
As an addendum to loi-nguyen-huynh's answer, for anyone encountering the eslint exhaustive-deps warning, this can be resolved by first breaking the stringified JSON out into a variable:
const depsString = JSON.stringify(deps);
React.useEffect(() => {
...
}, [depsString]);
I would recommend looking into this OSS package which was created to address the exact issue you describe (deeply comparing the values in the dependency array instead of shallow):
https://github.com/kentcdodds/use-deep-compare-effect
The usage/API is exactly the same as useEffect but it will compare deeply.
I would caution you however to not use it where you don't need it because it has the potential to result in a performance degredation due to unnecessary deep comparisons where a shallow one would do.
Quick solution, though its kinda of a hack:
const [string, setString] = useState('1');
useEffect(() => {
console.log(outcomes)
}, [string])
And when you update the array 'outcomes' also update the string like this
setString(prev => `${prev}2`)

Resources