Skip to content
useInfiniteQuery실무 경험

Improving infinite scroll search list state management with useInfiniteQuery

5 min readWorkMoved from my Tistory blogRead the Korean original

How I found the problem

The Autowini unified search page is an infinite scroll style list. After the search results are shown, a More search results button (the More button from here on) appears at the bottom if there is a next page, and pressing it appends the next page of results. While checking the page, I noticed something odd. The search list was still empty, but the More button was already showing.

When I debugged it, I found that the page called a secondary API (recommended listings inserted into the middle of the list) along with the main search API, and the More button's display condition only looked at the main query's isLoading. When the secondary API response was late, the list merge was delayed but the button condition was already true, so the button appeared before the list.

Fixing a single condition would have been enough to stop the bug itself. But when I looked into the code, the problem was not the condition. It was the structure itself, which copied server data into useState and synced it by hand with useEffect. So instead of only fixing the condition, I refactored it with useInfiniteQuery.

Problems with the old code

The old structure is below. I simplified it from the real code to make it easier to follow, and since the merge logic that inserts recommended listings between cars is the same before and after the improvement, I moved it into a helper function.

ts
utils/mergeCarsAndPromos.ts

// Inserts recommended listings between cars in the list (one per 10 cars)
export const mergeCarsAndPromos = (cars: Car[], promoCars: PromoCar[]) => {
  const merged: (Car | PromoCar)[] = [];

  cars.forEach((car, index) => {
    merged.push(car);
    const promo = promoCars[Math.floor(index / 10)];
    if ((index + 1) % 10 === 0 && promo) merged.push(promo);
  });

  return merged;
};
jsx
SearchPage.tsx (before)

export default function SearchPage() {
  const [page, setPage] = useState(1);
  // List state that copies and accumulates server responses
  const [displayList, setDisplayList] = useState<(Car | PromoCar)[]>([]);

  const { cars, totalCount, isLoading } = useCarList({ keyword, sort, page }); // Main search
  const { promoCars, promoIsLoading } = usePromoList({ keyword, page }); // Secondary search (recommended listings)

  // Reset the accumulated list by hand when the keyword changes
  useEffect(() => {
    setDisplayList([]);
    setPage(1);
  }, [keyword]);

  // When both API responses arrive, merge them in the effect and append to the list
  useEffect(() => {
    if (cars && !promoIsLoading) {
      setDisplayList([...displayList, ...mergeCarsAndPromos(cars, promoCars ?? [])]);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cars, promoCars]);

  // More button: increments the page number by hand
  const handleMore = () => setPage((prev) => prev + 1);

  return (
    <>
      {displayList.map((item) => (
        <ResultCard key={item.id} item={item} />
      ))}
      {/* More button display condition: decided by the main query only */}
      {!isLoading && totalCount > displayList.length && (
        <MoreButton onClick={handleMore} />
      )}
    </>
  );
}

This code structure had a few problems.

  • The server response is already in the TanStack Query cache, but it gets copied again into displayList. The same data lives in two places, so the component takes on all the work of keeping them in sync.
  • Even when data arrives, it can't be drawn right away. It has to go around one more time through render → useEffect → setState → re-render, and in between there are moments of flicker or of only the button showing up first.
  • The merge effect has !promoIsLoading as its condition, so when the secondary API is late, only the list merge is delayed and the rest of the UI doesn't know about it. The bug I first found was exactly at this point.
  • Every time a condition changes, such as the keyword or the sort order, setDisplayList([]) has to be called by hand. Missing it in even one place mixes the previous results into the new ones.

Improving it with useInfiniteQuery

I handed page accumulation over to TanStack Query and changed the merged list for the screen so it is derived during render with useMemo. First, the API hook.

ts
api/useCarList.ts

import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query';

interface CarListResponse {
  items: Car[];
  totalCount: number;
}

// Return only the body from the axios response to keep later data access simple
const fetchCarList = async (keyword: string, sort: string, page: number) => {
  const { data } = await api.get<CarListResponse>('/cars', {
    params: { keyword, sort, page },
  });
  return data;
};

// Why select: keep the cached original (the full response) as is and pick out only the shape the screen uses (an items array per page)
// Why module level: an inline function inside the component is recreated on every render, which breaks memoization of the select result
const selectCarList = (data: InfiniteData<CarListResponse>) => ({
  pages: data.pages.map((page) => page.items),
  totalCount: data.pages[0]?.totalCount ?? 0,
});

export const useCarList = ({ keyword, sort }: CarListParams) => {
  const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      // Only search conditions go in the key. pageParam manages the page number, and accumulated pages reset automatically when the conditions change
      queryKey: ['carList', keyword, sort],
      // TanStack Query passes in pageParam (the page number)
      queryFn: ({ pageParam }) => fetchCarList(keyword, sort, pageParam),
      enabled: !!keyword, // Call only when there is a keyword
      initialPageParam: 1, // The first request is page 1
      // Compute the next page number. Returning undefined means the last page (hasNextPage is false)
      getNextPageParam: (lastPage, allPages) => {
        // Number of items loaded so far
        const loadedCount = allPages.flatMap((page) => page.items).length;
        // Stop on an empty page or once everything is loaded. Guards against the More button staying forever when totalCount is off
        if (lastPage.items.length === 0 || loadedCount >= lastPage.totalCount) {
          return undefined;
        }
        return allPages.length + 1;
      },
      select: selectCarList,
    });

  return {
    carPages: data?.pages ?? [],
    totalCount: data?.totalCount,
    isLoading,
    isFetchingNextPage,
    hasNextPage,
    fetchNextPage,
  };
};

I moved the secondary search hook (usePromoList) to the same structure, and included sort in its queryKey just like the main one, so the page indexes of the two queries always match 1:1. The component becomes simpler, like this.

jsx
SearchPage.tsx (after)
export default function SearchPage() {
  // TanStack Query manages page accumulation, resets, and whether there is a next page
  const { carPages, totalCount, isFetchingNextPage, hasNextPage, fetchNextPage } =
    useCarList({ keyword, sort });
  const { promoPages, promoIsFetchingNextPage, fetchPromoNextPage } =
    usePromoList({ keyword, sort });

  // Compute the list during render from the pages in the cache. With no effect + setState step, there is no extra render or flicker
  // Why useMemo: to skip the merge and reuse the previous array on renders where the page data hasn't changed
  const displayList = useMemo(
    () => carPages.flatMap((cars, i) => mergeCarsAndPromos(cars, promoPages[i] ?? [])),
    [carPages, promoPages]
  );

  // More button: request the next main and secondary pages together (keeps page indexes 1:1)
  const handleMore = () => {
    fetchNextPage();
    fetchPromoNextPage();
  };

  return (
    <>
      {displayList.map((item) => (
        <ResultCard key={item.id} item={item} />
      ))}
      {/* Show only when there is a next page and neither query is fetching */}
      {hasNextPage && !isFetchingNextPage && !promoIsFetchingNextPage && (
        <MoreButton onClick={handleMore} />
      )}
    </>
  );
}

The useInfiniteQuery options I used

queryKey Required The cache identifier. The key point is to put in only the search conditions, not the page number. When the conditions change, the key changes and the accumulated pages reset automatically, so manual resets like the old setDisplayList([]) all go away.
queryFn Required Receives { pageParam } as its argument. If you use this value as the request parameter, you don't need to manage page number state yourself.
initialPageParam Required (v5) The pageParam passed to the first request. I set it to 1 so it starts from page 1.
getNextPageParam Required Takes (lastPage, allPages) and returns the next pageParam. Returning undefined is treated as the last page, and this result becomes hasNextPage. In other words, the logic that decides "can we load more?" moves from a condition in the component into the API hook. I compare the number of loaded items with totalCount, and to keep the More button from staying forever when the server's totalCount doesn't match the actual number of items, I added a guard that stops right away when an empty page comes in.
select Optional Turns the cached original into data for the screen. It doesn't touch the cache itself, so you can keep the original and the derived data separate. One thing to watch: if you pass an inline function, it is recreated on every render and breaks memoization, so you need to define it as a module-level function to keep the reference stable.
enabled Optional I set it to !!keyword so it only calls when there is a keyword. It works the same as in useQuery.

From the return values, I used three.

  • fetchNextPage: Requests the next page. It replaces the old "setState that increases page by 1".
  • hasNextPage: true when getNextPageParam doesn't return undefined. It is what decides whether to show the More button.
  • isFetchingNextPage: Whether the next page is being requested. It is separate from the first load (isLoading), so the extra loading skeleton and hiding the button can be handled separately.

The bug I first found was also fixed along the way. The More button's condition changed from "is the main query loading?" to a declarative one, "is there a next page, and is neither query fetching?", so the structure no longer lets the button show up before the list, even when the secondary API is late.

But did 'performance' actually get better?

This is a question I asked myself during the refactor. Holding the list in useState versus useInfiniteQuery holding it in its cache: both are JavaScript heap memory anyway, so is there a performance difference?

The short answer is that storage itself makes no difference. TanStack Query's cache is also just an object in memory. The useState copy is gone, but what goes into the array is references to the same objects anyway, so it doesn't even count as a memory saving. So I couldn't describe this refactor as "faster thanks to caching."

Still, something did change. The number of renders. Before, after data arrived, setState ran through the effect and rendering went around one more cycle. Now the list is built during render with useMemo, so that render is gone.

In the end, what this improvement gained was correctness and maintainability rather than performance. It removed room for bugs from the structure, like behavior that depended on the order the two APIs arrived in and the chance of missing a manual reset. And with the page accumulation, reset, and merge sync code I used to manage by hand gone, there is less code to touch from here on.

What I took away

I felt firsthand that the moment you copy server state into useState, the job of keeping that data in sync moves from the library to your own code. I had to handle the reset timing, the merge order, and the loading conditions all by myself, and the More button bug I first found came from missing one of them. If you use TanStack Query and are moving its responses into useState, I think that is a sign worth questioning.

For now, I applied this to the Autowini unified search page first. The PartWini unified search page, which uses the same infinite scroll list pattern, still has the same structure, so I plan to refactor it consistently using this pattern as the baseline.

  • #infinite scroll
  • #React
  • #TanStack Query
  • #TypeScript
  • #useInfiniteQuery
  • #frontend
  • #frontend development

More in Work