martin
martin

Reputation: 885

React custom hook rendering infinitely

I made a custom hook that fetches a News API and returns a handler for loading, errors and data (inspired by Apollo Client). The problem is that when using it, it will fire itself infinitely, even though the items in the dependency array don't change. This is how I'm implementing it:

The hook:

const useSearch = (query: string, sources: string[]) => {
  const [response, setResponse] = useState<State>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    newsapi
      .getEverything({
        q: query,
        pageSize: 1,
        sources: sources,
      })
      .then((data) => {
        setResponse({ data, loading: false, error: null });
      })
      .catch((e) => {
        setResponse({ data: null, loading: false, error: e });
      });
  }, [query, sources]);

  return response;
};

Usage:

  const { loading, error, data } = useSearch("Donald", ["bbc-news"]);

And I exceeded my daily rate for the API:

enter image description here

What am I doing wrong?

Upvotes: 0

Views: 138

Answers (1)

hackape
hackape

Reputation: 19957

I provided the solution, and @JacobSmit explained in the comment section. Now I just organize them into an answer with more details, hope it'd be helpful to latecomer.

Solution

const useSearch = (query: string, sources: string[]) => {
  // ...
  useEffect(() => {
    // ...

    // FIX:
    // just apply the spread operator (...) to `sources`
    // to spread its elements into the dependency array of `useEffect`
  }, [query, ...sources]);

  return response;
};

Explanation

The useSearch custom hook passes [query, sources] to the dep array of useEffect, where as sources: string[] is an array itself. That makes the dep array of shape:

["query", ["source_1", "source_2", ..., "source_n"]]

See that the second element of dep array is a nested array. However, the way useEffect consumes the dep array, is to apply Object.is equality check to each of it's elements:

// pseudo code
function isDepArrayEqual(prevDepArray: any[], currDepArray: any[]) {
  return prevDepArray.every(
    (prevElement, index) => Object.is(prevElement, currDepArray[index])
  )
}

With each re-render, the hook call useSearch("Donald", ["bbc-news"]) creates a new instance of sources array. That'll fail the Object.is(prevSources, currSources) check, since equality of arrays is compared by their reference, not the value(s) they contain.

With the spread operator [query, ...sources], you transform the shape of dep array into:

["query", "source_1", "source_2", ..., "source_n"]

The key difference is not about copying, but unpacking the sources array.

Now that the nested sources array is unpacked, and each element of dep array is just string. A equality check on strings is compared by their value, not reference, thus useEffect will consider dep array unchanged. Bug fixed.

Upvotes: 1

Related Questions