Passing array to useEffect dependency list
Asked Answered
S

8

106

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)
)
Seldon answered 24/12, 2019 at 10:58 Comment(3)
Not enough context, please include the code that is actually causing the infinite loopEmmi
Spreading the array is no good. App starts with an empty array as default, so useEffect will throw error about different number of dependencies between rerenders.Seldon
Disappointing that none of these answers work with without warnings from react-hooks/exhaustive-depsItu
J
171

You can pass JSON.stringify(outcomes) as the dependency list:

Read more here

useEffect(() => {
  console.log(outcomes)
}, [JSON.stringify(outcomes)])
Juta answered 24/12, 2019 at 11:38 Comment(17)
I'm quite sure this is the answer the OP wants. The idea also isn't made up by me. It's from Dan Abramov (React core team member). If anyone sees there's a problem in my answer, please tell me what it is so that I can improve that answer to be better.Juta
@Seldon Have you checked my answer?Juta
Yeah, I think I will actually give it a go. What's holding me back is 'react-hooks/exhaustive-deps' warning about oucomes being missing from deps. I know it makes no sense to add it since we added JSON.stringify(outcomes) but stillSeldon
@Seldon to appease the linting, pass outcomes to the dependency list isn't a bad idea. Execute this when outcomes change (being assigned to another array), or one of the items was changed. The difference is when the array is being assigned to another array with the exact same identity, the effect is still executed.Juta
I'm curious what you ended up with?Juta
Hey, I ended up with JSON.stringify(outcomes) as dependency arraySeldon
@LoiNguyenHuynh: I know why the answer works and I think it's a good answer, but with all the respect that I have for the React team because React is an amazing framework to build web apps with... this thing of passing arrays or objects to useEffect/useMemo/useCallback's dependencies really keeps coming back to me and I'm starting to get tired of using work-arounds. Is there any way we can discuss improvements somewhere? I don't know where to start.Visually
@PatrickDaSilva I guess opening a new issue in their repository might help.Juta
this works perfect, but got complain from eslintSanjiv
This causes ESLint to warn both about a missing dependency and a complex expression - both under the react-hooks/exhaustive-deps rule, which I really don't want to ignore. Maybe a custom deeply comparing hook would solve this? I don't know if ESLint would recognize a custom hook.Lowly
shouldnt we use .sort() and then stringify? what if the items change but length stayed the same?Jacquard
@Jacquard it's JSON.stringify(array), not array.lengthJuta
My question still stands I think: the stringified could have same items but in a different order and that would still fire the useEffect, correct?Jacquard
@VsevolodGolovanov react-hooks/exhaustive-deps can be configured with additional hooks to be checked.Schnitzler
Am I the only getting the ESLint warning React Hook useEffect has missing dependencies: outcomes when using this method? The workaround is const str = JSON.stringify(outcomes); useEffect(() => { const outcomes = JSON.parse(str); console.log(outcomes)}, [str]) but it's kinda verbose.Cumin
This should not be the answer. As noted in other comments, it does not fix the eslint warning. The React team repeatedly has said if you need to disable the warnings, you are doing something wrong.Itu
@Itu so what IS the right answer?Pub
C
19

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

Edit 59467758/passing-array-to-useeffect-dependency-list

Cloudcapped answered 16/3, 2021 at 10:51 Comment(7)
This is the correct way to solve the problem (using useRef).Woald
This answer is wrong, useRef().current can not be used in a dependency array: medium.com/welldone-software/…Trypanosome
@flyingsheep It's your argument that is wrong. Did you read the code and try running the demo? ref.current is a middle man variable that is used to store the new value which invokes the effect callback depend on the equality check. The one that triggers a re-render is the dependency value from the outside, not ref.current.Cloudcapped
The answer contains useEffect(cb, [ref.current]), which is equivalent to useEffect(cb).Trypanosome
@flyingsheep they're different as seen in this demo, you can see the modified codesandbox here. the effect with the ref.current dependency only runs when the the arr prop changes, the ref is never meant to be reactive as intended.Cloudcapped
Thank you for the exhaustive code and explanation @NearHuscarl, but I can't help being disgusted and refuse to write so much code for something that should be shipped out of the box. AFAIC I will JSON.stringify it and silence the warning.Magic
I like this approach, and I adopted it. However, React complains that cb needs to be added as a dep to the useEffect call in useCustomEffect, but doing that wrecks my code. The array whose mutation I'm tracking as an effect is a list of options for a select. When I add cb as a dep, the select immediately closes every time I select it, so I see the options only for an instant. I added an eslint exception so I can use this solution, but the whole point of your answer was to provide a solution compatible with React dev guidance, so I'd like to know why this solution breaks the code as-is.Pairoar
F
13

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 ])
Fertilize answered 15/1, 2021 at 0:1 Comment(3)
Be aware that if you mix string with number in the array, the check may fail because `${[1,2,3]}` is the same as `${[1,2,'3']}`. But storing values with different types in the same array is a bad idea anyway..Cloudcapped
Unfortunately spreading returns a warning to me: React Hook useEffect has a spread element in its dependency array. This means we can't statically verify whether you've passed the correct dependenciesMagic
The first one is the same as writing outcomes.join()Interlaken
I
4

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]);
Inexpugnable answered 12/11, 2021 at 18:28 Comment(1)
You can't use deps inside the hook without getting a different exhaustive-deps warning, however. You also get a warning for not using the depsString in the hook. Is your answer to have to JSON.parse it within the effect? That seems ridiculous, honestly.Itu
B
2

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.

Bewley answered 12/10, 2021 at 20:21 Comment(0)
G
0

The solution I am suggesting here will match some scenarios. What I am doing for these kinds of issues is to memoize the variable and then useEffect will not fire indefinitely.

Please note this is not 100% match to the question asked above but I guess this will help someone.

const roles = useMemo(() => [EntityRole.Client, EntityRole.Agency], []);

useEffect(() => {
  console.log(roles)
}, [roles])

Goar answered 22/8, 2023 at 9:14 Comment(0)
H
0

To fix linting issue, you can parse stingify variable within useEffect

  const getNames = (names) => {
      // stingify names list first
      const nameStr = JSON.stringify(names);

      useEffect(() => {
          // use stingify variable so useEffect don't have dependency on names
          const nameList = JSON.parse(nameStr);
          console.log(nameList);
      }, [nameStr]);
  };
Heisser answered 12/9, 2023 at 7:38 Comment(1)
I hate it, but it does seem to be the simplest approach.Dermatophyte
C
-1

In my case, I used the following approach to overcome such a situation: I simply passed an object containing the array.

useEffect(() => {
  console.log(outcomes)
}, [{outcomes}])
Carnatic answered 25/7, 2023 at 16:6 Comment(1)
Creating a new object like this is just the same as not passing any deps: it will run each render.Watercolor

© 2022 - 2024 — McMap. All rights reserved.