javascript – React: How to avoid an infinite loop when fetch uses array declared in useState then updates that array

I’m implementing a “user profile stack” for a dating app. I have a GraphQL API with lots of user profiles (thousands). I only display the data for a single profile at a time, but I want to pre-fetch upcoming profiles in the background. I fetch an additional 5 profiles anytime you get close the end of the already fetched profiles. I’m essentially paginating through the profiles 5 at a time. But I don’t want to store all of the fetched profiles in memory. Also, there’s no need to re-view profiles you’ve already seen so it’s safe to forget about the previous profiles, thus they don’t need to be kept in memory (actually, there is a button to go back a single profile, but that’s handled in the code).

Based on these constraints, I only keep a sliding window of the 10 most recently fetched profiles. So my profiles array only ever has at most 10 elements in it. The way it works is I first fetch 5 profiles (first half), then when index === 1 I fetch the next 5 profiles (second half). When index === 6 (no longer need the first 5 profiles), I fetch 5 more profiles and replace the original first 5 profiles with the new profiles. When index gets to the end of the profiles array, it wraps back to the beginning to view the newly fetched profiles. Then when index === 1 (no longer need the second batch of profiles), I fetch 5 more profiles and replace the second half of the users array with those profiles. This repeats until the user eventually runs out of new profiles to fetch.

And crucially, every time a new batch of profiles are fetched, I need to exclude the user ids for the current profiles. Otherwise my fetch would include some (exactly 4) of the profiles that I had fetched recently but not yet viewed. I only need to worry about excluding the ids for the current profiles because when you move past a profile (swipe left/right), that action executes a mutation which permanently removes that profiles from the list of possible profiles that can be shown to you. So really the only ids that need to be excluded are the ones that were pre-fetched but you haven’t viewed yet. In my example, I’m excluding all of the current ids though because it makes the code simpler and there’s no harm in excluding extra ids for profiles that you’ve already made a decision on.

Here’s a simplified version of my code:

const FETCH_SIZE = 5;

// Code for handling errors, loading, and
// reaching the end of the profiles omitted for simplicity.
function Component() {
  const [profiles, setProfiles] = useState([]);
  const [index, setIndex] = useState(0);

  // Fetch first half of profiles. This only runs once on initial render.
  useEffect(() => {
    async function fetchAndUpdate() {
      const newProfiles = await fetchProfiles({ limit: FETCH_SIZE });
      setProfiles(newProfiles);
    }
    fetchAndUpdate();
  }, [fetchProfiles]);

  // Fetch second half of profiles when index === 1
  // Overwrite second half of profiles if there are
  // already profiles present in that part of the array.
  useEffect(() => {
    async function fetchAndUpdate() {
      const excludeIds = profiles.map((p) => (p.id));
      const newProfiles = await fetchProfiles({ limit: FETCH_SIZE, excludeIds });
      setProfiles((prev) => [
        ...prev.slice(0, FETCH_SIZE),
        ...newProfiles,
      ]);
    }
    if (index === 1) {
      fetchAndUpdate();
    }
  }, [fetchProfiles, index]);

  // Fetch first half of profiles when index === 6
  // Overwrite first half of profiles if there are
  // already profiles present in that part of the array.
  useEffect(() => {
    async function fetchAndUpdate() {
      const excludeIds = profiles((p) => (p.id));
      const newProfiles = await fetchProfiles({ limit: FETCH_SIZE, excludeIds });
      setProfiles((prev) => [
        ...newProfiles,
        ...prev.slice(FETCH_SIZE),
      ]);
    }
    if (index === 6) {
      fetchAndUpdate();
    }
  }, [fetchProfiles, index]);

  return (
    <div>
      <p>{profiles[index].name}</p>
      <button onClick={() => loopingIncrement(index, setIndex, profiles)}>
        Next
      </button>
      {/* Button for going back by one profile omitted for simplicity */}
    </div>
  );
}

function loopingIncrement(index, setIndex, profiles) {
  if (index === profiles.length - 1) {
    // Loop back to the beginning if you're on the last profile
    setIndex(0);
  } else {
    setIndex(index + 1);
  }
}

This code works as expected. However, I get a react-hooks/exhaustive-deps warning on the dependency arrays for the second two useEffects because I’m using profiles but didn’t include it in the dependency array. If I add profiles to the dependency array like the warning suggests, my code enters an infinite loop as soon as index === 1 because setUsers replaces the users with a new array of users which, since React only does a shallow comparison of the dependencies, looks like a new array to React and triggers useEffect to run again.

I am aware that I could just ignore this warning by adding // eslint-disable-next-line react-hooks/exhaustive-deps. But I don’t want to do that because I’m worried that I’m opening myself up to bugs. See this post for why ignoring the warning is bad (“removing a dependency you use (or blindly specifying) []) is usually the wrong fix”).

The usual way to fix this issue is to not use profiles directly inside of useEffect and instead use the update function in setState like setProfiles((prev) => [...]) but since profiles is used by fetchProfiles for getting the ids to exclude, to do it this way I would need to move fetchProfiles inside of the setProfiles function. But it doesn’t seem like you can put async code inside of the setProfiles function. I tried to do

setProfiles(async (prev) => {
  const excludeIds = prev.map((p) => (p.id));
  const newProfiles = await fetchProfiles({ limit: FETCH_SIZE, excludeIds });
  return [
    ...prev.slice(0, FETCH_SIZE),
    ...newProfiles,
  ];
});

But this breaks because now I’m setting profiles to a Promise instead of an array.

How do I fix this? I searched high and low but couldn’t find any examples of what to do if your async data fetching requires a value created in a useState hook and you can’t remove it from useEffect because you can’t put async code inside of the setState update function.

The other solution I considered was tricking React into doing a deep comparison of profiles in the useEffect dependencies array. Like this package does. But that seems like a hack, and I feel like there’s a better way to accomplish what I’m trying to do. And if I’m going with a potentially risky hack, I’d rather just go with the simpler solution of ignoring the warning and hoping for the best. Even the docs for that package include “WARNING: Please only use this if you really can’t find a way to use React.useEffect. There’s often a better way to do what you’re trying to do than a deep comparison.” But what’s the better way in this case?

Leave a Comment