javascriptreactjsreact-hooks

How to address (not ignore) react exhaustive-deps linter rule without causing infinite rerender loop when fetching data


In real life I would probably be using react-query for this, but to solidify my understanding, I am trying to implement basic pagination in a minimal hacker news jobs board clone with useReducer and useEffect alone. The following code works, but results in an exhaustive-deps linter warning:

React Hook useEffect has a missing dependency: 'listings'. Either include it or remove the dependency array.

import { useEffect, useReducer } from "react";

const initialState = {
  page: 1,
  listings: [],
  ids: [],
  initializationStatus: "loading",
};

function jobListingReducer(state, action) {
  switch (action.type) {
    case "incrementPage":
      return { ...state, page: state.page + 1 };

    case "decrementPage":
      return { ...state, page: state.page - 1 };

    case "getListings":
      return {
        ...state,
        initializationStatus: "fulfilled",
        ids: action.payload.ids,
        listings: action.payload.listings,
      };

    default:
      return state;
  }
}

async function fetchIds() {
  const response = await fetch(
    "https://hacker-news.firebaseio.com/v0/jobstories.json"
  );
  return await response.json();
}

async function fetchListings(ids) {
  const responses = await Promise.all(
    ids.map((id) => fetch(
      `https://hacker-news.firebaseio.com/v0/item/${id}.json`
    ))
  );

  return await Promise.all(
    responses.map((response) => response.json())
  );
}

const LISTINGS_PER_PAGE = 6;

export default function JobBoard2() {
  const [state, dispatch] = useReducer(jobListingReducer, initialState);
  const { ids, listings, initializationStatus, page } = state;

  const startIndexForDisplayedListings = (page - 1) * LISTINGS_PER_PAGE;
  const endIndexForDisplayedListings =
    startIndexForDisplayedListings + LISTINGS_PER_PAGE;
  const displayedListings = listings.slice(
    startIndexForDisplayedListings,
    endIndexForDisplayedListings
  );

  const hasMore = ids.length > page * LISTINGS_PER_PAGE;

  useEffect(() => {
    async function getListings() {
      let idsForListings = ids.length ? ids : await fetchIds();
      const startIndex = page === 1
        ? 0
        : page * LISTINGS_PER_PAGE - LISTINGS_PER_PAGE;
      const endIndex = LISTINGS_PER_PAGE * page;
      const hasNeededListings = listings.length >= endIndex;
      const newListings = hasNeededListings
        ? []
        : await fetchListings(idsForListings.slice(startIndex, endIndex));

      dispatch({
        type: "getListings",
        payload: {
          ids: idsForListings,
          listings: [...listings, ...newListings],
        },
      });
    }
    getListings();
  }, [page, ids]);

  if (initializationStatus === "loading") {
    return <p>Loading...</p>;
  }

  return (
    <>
      {page > 1 && <button onClick={() => dispatch({ type: "decrementPage" })}>
          prev page
        </button>
      }
      {hasMore && <button onClick={() => dispatch({ type: "incrementPage" })}>
          next page
        </button>
      }
      <ul>
        {displayedListings.map((listing) => (
          <li key={listing.id}>{listing.title}</li>
        ))}
      </ul>
    </>
  );
}

But adding listings into the dependency array causes an infinite re-render loop. I've long treated this scenario as an instance where I just disable the warning, but wonder if my mental model for this is wrong and there is actually a valid way to address the linter warning (other than using react-query or some other abstraction that takes care of this under the hood).

Edit: one of the answers so far uses abortController to handle a double-rendering bug it introduces (that is only seen in React.StrictModemode). I am a little skeptical that this is the way to handle. react-query does not use abortController by default, and accomplishing the functionality above with react-query is relatively straightforward:

import {useQueries, useQuery} from "@tanstack/react-query";
import {useState} from "react";

async function fetchIds() {
    const response = await fetch("https://hacker-news.firebaseio.com/v0/jobstories.json");
    return await response.json();
}

async function fetchListing(id) {
    const response = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);
    return await response.json();
}

const LISTINGS_PER_PAGE = 10;

export default function JobBoard3() {
    const {data: ids, isLoading: isLoadingIds} = useQuery({queryKey: ["ids"], queryFn: fetchIds});
    const [page, setPage] = useState(1);
    const startIndex = page * LISTINGS_PER_PAGE - LISTINGS_PER_PAGE;
    const endIndex = startIndex + LISTINGS_PER_PAGE;
    const displayedIds = ids?.slice(startIndex, endIndex) || [];

    const {results: listings, pending: isLoadingListings} = useQueries({
        queries: displayedIds?.map((id) => ({
            queryKey: ["listing", id],
            queryFn: () => fetchListing(id),
            enabled: !!displayedIds.length,
        })),
        combine: (results) => {
            return {
                results: results.map((result) => result.data),
                pending: results.some((result) => result.isPending),
            };
        },
    });

    if (isLoadingIds || isLoadingListings) {
        return <p>Is Loading...</p>;
    }

    const hasMore = ids.length > page * LISTINGS_PER_PAGE;

    return (
        <>
            {page > 1 && <button onClick={() => setPage(page - 1)}>prev page</button>}
            {hasMore && <button onClick={() => setPage(page + 1)}>next page</button>}
            <ul>
                {listings.map((result) => (
                    <li key={result.id}>{result.title}</li>
                ))}
            </ul>
        </>
    );
}

Solution

  • Move the [...listings, ...newListings], logic into your reducer where it belongs, it's what the reducers are for after all, e.g. computing the next state. This is so listings is removed as an external dependency.

    Example:

    function jobListingReducer(state, action) {
      switch (action.type) {
        case "incrementPage":
          return { ...state, page: state.page + 1 };
    
        case "decrementPage":
          return { ...state, page: state.page - 1 };
    
        case "getListings":
          const { ids, listings } = action.payload;
          return {
            ...state,
            initializationStatus: "fulfilled",
            ids,
            listings: [...state.listings, ...listings],
            // or
            // listings: state.listings.concat(...listings),
          };
    
        default:
          return state;
      }
    }
    
    useEffect(() => {
      async function getListings() {
        let idsForListings = ids.length ? ids : await fetchIds();
        const startIndex = page === 1
          ? 0
          : page * LISTINGS_PER_PAGE - LISTINGS_PER_PAGE;
        const endIndex = LISTINGS_PER_PAGE * page;
        const hasNeededListings = listings.length >= endIndex;
        const newListings = hasNeededListings
          ? []
          : await fetchListings(idsForListings.slice(startIndex, endIndex));
    
        dispatch({
          type: "getListings",
          payload: {
            ids: idsForListings,
            listings: newListings,
          },
        });
      }
      getListings();
    }, [page, ids]);
    

    There's now a bug where the first page of listings is inserted into state twice, so the first time "next page" is clicked, the same results show (although that does not occur on subsequent pagination events).

    That sounds like an issue caused using the React.StrictMode component is non-production builds double-mounting/double-useEffect-call, use an AbortController to cancel any in-flight fetch requests so the mount/remount logic works correctly. You might also try computing startIndex, endIndex, and hasNeededListings outside the useEffect callback, referenced as stable dependencies since they are strings/booleans.

    I was able to reproduce the problem above and I've tested the following implementation and it seems to work as expected within the StrictMode component.

    async function fetchIds(options = {}) {
      const response = await fetch(
        "https://hacker-news.firebaseio.com/v0/jobstories.json",
        options,
      );
      return response.json();
    }
    
    async function fetchListings(ids = [], options = {}) {
      return Promise.all(
        ids.map((id) =>
          fetch(
            `https://hacker-news.firebaseio.com/v0/item/${id}.json`,
            options
          ).then((response) => response.json())
        )
      );
    }
    
    const LISTINGS_PER_PAGE = 6;
    
    export default function JobBoard2() {
      const [state, dispatch] = useReducer(jobListingReducer, initialState);
    
      const { ids, listings, initializationStatus, page } = state;
    
      const startIndexForDisplayedListings = (page - 1) * LISTINGS_PER_PAGE;
      const endIndexForDisplayedListings =
        startIndexForDisplayedListings + LISTINGS_PER_PAGE;
      const displayedListings = listings.slice(
        startIndexForDisplayedListings,
        endIndexForDisplayedListings
      );
    
      const hasMore = ids.length > page * LISTINGS_PER_PAGE;
      const startIndex = page === 1
        ? 0
        : page * LISTINGS_PER_PAGE - LISTINGS_PER_PAGE;
      const endIndex = LISTINGS_PER_PAGE * page;
      const hasNeededListings = listings.length >= endIndex;
    
      useEffect(() => {
        const controller = new AbortController();
        const signal = controller.signal;
    
        const getListings = async () => {
          try {
            const idsForListings = ids.length ? ids : await fetchIds({ signal });
    
            const newListings = hasNeededListings
              ? []
              : await fetchListings(
                  idsForListings.slice(startIndex, endIndex),
                  { signal }
                );
    
            dispatch({
              type: "getListings",
              payload: {
                ids: idsForListings,
                listings: newListings
              }
            });
          } catch (error) {
            // catch any thrown exceptions or rejected Promises
          }
        };
    
        getListings();
    
        return () => {
          // Abort any in-flight fetch requests
          controller.abort();
        };
      }, [ids, startIndex, endIndex, hasNeededListings]);
    
      if (initializationStatus === "loading") {
        return <p>Loading...</p>;
      }
    
      return (
        <>
          {page > 1 && (
            <button onClick={() => dispatch({ type: "decrementPage" })}>
              prev page
            </button>
          )}
          {hasMore && (
            <button onClick={() => dispatch({ type: "incrementPage" })}>
              next page
            </button>
          )}
          <ul>
            {displayedListings.map((listing) => (
              <li key={listing.id}>{listing.title}</li>
            ))}
          </ul>
        </>
      );
    }