diff --git a/examples/query/react/infinite-queries/src/App.tsx b/examples/query/react/infinite-queries/src/App.tsx index 47f11536e9..442c3306d7 100644 --- a/examples/query/react/infinite-queries/src/App.tsx +++ b/examples/query/react/infinite-queries/src/App.tsx @@ -7,7 +7,9 @@ import { InfiniteScrollAbout, InfiniteScrollExample, } from "./features/infinite-scroll/InfiniteScrollExample" +import LimitOffsetExample from "./features/limit-offset/LimitOffsetExample" import { InfiniteScrollMaxPagesExample } from "./features/max-pages/InfiniteScrollMaxExample" +import PaginationInfScrollExample from "./features/pagination-infinite-scroll/PaginationInfScrollExample" import { PaginationExample } from "./features/pagination/PaginationExample" const Menu = () => { @@ -31,6 +33,16 @@ const Menu = () => { Bidirectional Cursor-Based Infinite Scroll +
  • + + Limit and Offset Infinite Scroll + +
  • +
  • + + Pagination Infinite Scroll + +
  • ) @@ -67,6 +79,11 @@ const App = () => { path="bidirectional-cursor-infinte-scroll" element={} /> + } /> + } + /> diff --git a/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts b/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts new file mode 100644 index 0000000000..63834f0cef --- /dev/null +++ b/examples/query/react/infinite-queries/src/app/useIntersectionCallback.ts @@ -0,0 +1,22 @@ +import { useCallback, useRef } from "react" + +export function useIntersectionCallback(onIntersectCallback: () => void) { + const intersectionObserverRef = useRef(null) + + return useCallback( + (node: HTMLDivElement | null) => { + if (intersectionObserverRef.current) { + intersectionObserverRef.current.disconnect() + } + + intersectionObserverRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) { + onIntersectCallback() + } + }) + + if (node) intersectionObserverRef.current.observe(node) + }, + [onIntersectCallback], + ) +} diff --git a/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx index e4a15ceb26..5011057290 100644 --- a/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx +++ b/examples/query/react/infinite-queries/src/features/bidirectional-cursor-infinite-scroll/BidirectionalCursorInfScroll.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react" import { Link, useLocation } from "react-router" +import { useIntersectionCallback } from "../../app/useIntersectionCallback" import { apiWithInfiniteScroll } from "./infiniteScrollApi" const limit = 10 @@ -28,14 +29,8 @@ function BidirectionalCursorInfScroll({ startingProject = { id: 25 } }) { }, ) - const beforeRef = useIntersectionObserver({ - isFetching, - callback: fetchPreviousPage, - }) - const afterRef = useIntersectionObserver({ - isFetching, - callback: fetchNextPage, - }) + const beforeRef = useIntersectionCallback(fetchPreviousPage) + const afterRef = useIntersectionCallback(fetchNextPage) const location = useLocation() @@ -82,7 +77,7 @@ function BidirectionalCursorInfScroll({ startingProject = { id: 25 } }) { height: "400px", }} > -
    +
    {data?.pages.map(page => ( {page.projects.map((project, index, arr) => { @@ -109,7 +104,7 @@ function BidirectionalCursorInfScroll({ startingProject = { id: 25 } }) { })} ))} -
    +
    +
    +
    + {combinedData?.map((project, index, arr) => { + return ( +
    +
    +
    {`Project ${project.id} (created at: ${project.createdAt})`}
    +
    +
    + ) + })} + +
    +
    +
    + +
    +
    + {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? "Background Updating..." + : null} +
    + + +
    + + Go to another page + +
    + ) +} + +export default LimitOffsetExample diff --git a/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts b/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts new file mode 100644 index 0000000000..27bd2998a7 --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/limit-offset/infiniteScrollApi.ts @@ -0,0 +1,72 @@ +import { baseApi } from "../baseApi" + +type Project = { + id: number + createdAt: string +} + +export type ProjectsResponse = { + projects: Project[] + numFound: number + serverTime: string +} + +interface ProjectsInitialPageParam { + offset: number + limit: number +} + +export const apiWithInfiniteScroll = baseApi.injectEndpoints({ + endpoints: builder => ({ + projectsLimitOffset: builder.infiniteQuery< + ProjectsResponse, + void, + ProjectsInitialPageParam + >({ + infiniteQueryOptions: { + initialPageParam: { + offset: 0, + limit: 20, + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + const nextOffset = lastPageParam.offset + lastPageParam.limit + const remainingItems = lastPage?.numFound - nextOffset + + if (remainingItems <= 0) { + return undefined + } + + return { + ...lastPageParam, + offset: nextOffset, + } + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + const prevOffset = firstPageParam.offset - firstPageParam.limit + if (prevOffset < 0) return undefined + + return { + ...firstPageParam, + offset: firstPageParam.offset - firstPageParam.limit, + } + }, + }, + query: ({ offset, limit }) => { + return { + url: `https://example.com/api/projectsLimitOffset?offset=${offset}&limit=${limit}`, + method: "GET", + } + }, + }), + }), +}) diff --git a/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx new file mode 100644 index 0000000000..c461b0a09c --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/PaginationInfScrollExample.tsx @@ -0,0 +1,129 @@ +import { createSelector } from "@reduxjs/toolkit" +import { + BaseQueryFn, + TypedUseQueryStateResult, +} from "@reduxjs/toolkit/query/react" +import { Link, useLocation } from "react-router" +import { useIntersectionCallback } from "../../app/useIntersectionCallback" +import { apiWithInfiniteScroll, ProjectsResponse } from "./infiniteScrollApi" + +type ProjectsInfiniteQueryResult = TypedUseQueryStateResult< + { pages: ProjectsResponse[] }, + unknown, + BaseQueryFn +> + +const selectCombinedProjects = createSelector( + (res: ProjectsInfiniteQueryResult) => { + return res.data + }, + data => data?.pages?.map(item => item?.projects)?.flat(), +) + +function PaginationInfScrollExample() { + const { + combinedData, + hasPreviousPage, + hasNextPage, + // data, + error, + isFetching, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = apiWithInfiniteScroll.endpoints.projectsPaginated.useInfiniteQuery( + undefined, + { + selectFromResult: result => { + return { + ...result, + combinedData: selectCombinedProjects(result), + } + }, + }, + ) + + const intersectionCallbackRef = useIntersectionCallback(fetchNextPage) + const location = useLocation() + + return ( +
    +

    Pagination Infinite Scroll

    + {isLoading ? ( +

    Loading...

    + ) : isError ? ( + Error: {error.message} + ) : null} + + <> +
    + +
    +
    + {combinedData?.map((project, index, arr) => { + return ( +
    +
    +
    {`Project ${project.id} (created at: ${project.createdAt})`}
    +
    +
    + ) + })} + +
    +
    +
    + +
    +
    + {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? "Background Updating..." + : null} +
    + + +
    + + Go to another page + +
    + ) +} + +export default PaginationInfScrollExample diff --git a/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts new file mode 100644 index 0000000000..c519dce5bb --- /dev/null +++ b/examples/query/react/infinite-queries/src/features/pagination-infinite-scroll/infiniteScrollApi.ts @@ -0,0 +1,72 @@ +import { baseApi } from "../baseApi" + +type Project = { + id: number + createdAt: string +} + +export type ProjectsResponse = { + projects: Project[] + serverTime: string + totalPages: number +} + +interface ProjectsInitialPageParam { + page: number + size: number +} + +export const apiWithInfiniteScroll = baseApi.injectEndpoints({ + endpoints: builder => ({ + projectsPaginated: builder.infiniteQuery< + ProjectsResponse, + void, + ProjectsInitialPageParam + >({ + infiniteQueryOptions: { + initialPageParam: { + page: 0, + size: 20, + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + const nextPage = lastPageParam.page + 1 + const remainingPages = lastPage?.totalPages - nextPage + + if (remainingPages <= 0) { + return undefined + } + + return { + ...lastPageParam, + page: nextPage, + } + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + const prevPage = firstPageParam.page - 1 + if (prevPage < 0) return undefined + + return { + ...firstPageParam, + page: prevPage, + } + }, + }, + query: ({ page, size }) => { + return { + url: `https://example.com/api/projectsPaginated?page=${page}&size=${size}`, + method: "GET", + } + }, + }), + }), +}) diff --git a/examples/query/react/infinite-queries/src/mocks/handlers.ts b/examples/query/react/infinite-queries/src/mocks/handlers.ts index e205f63709..f6d747268b 100644 --- a/examples/query/react/infinite-queries/src/mocks/handlers.ts +++ b/examples/query/react/infinite-queries/src/mocks/handlers.ts @@ -134,12 +134,9 @@ export const handlers = [ const hasPreviousPage = startCursor !== 0 await delay(1000) - - const serverTime = Date.now() - return HttpResponse.json({ projects: resultProjects, - serverTime, + serverTime: Date.now(), pageInfo: { startCursor, endCursor, @@ -154,4 +151,64 @@ export const handlers = [ } }, ), + http.get( + "https://example.com/api/projectsLimitOffset", + async ({ request }) => { + const url = new URL(request.url) + const limit = parseInt(url.searchParams.get("limit") ?? "5", 10) + let offset = parseInt(url.searchParams.get("offset") ?? "0", 10) + + if (isNaN(offset) || offset < 0) { + offset = 0 + } + if (isNaN(limit) || limit <= 0) { + return HttpResponse.json( + { + message: + "Invalid 'limit' parameter. It must be a positive integer.", + }, + { status: 400 }, + ) + } + + const result = projects.slice(offset, offset + limit) + + await delay(1000) + return HttpResponse.json({ + projects: result, + serverTime: Date.now(), + numFound: projects.length, + }) + }, + ), + http.get("https://example.com/api/projectsPaginated", async ({ request }) => { + const url = new URL(request.url) + const size = parseInt(url.searchParams.get("size") ?? "5", 10) + let page = parseInt(url.searchParams.get("page") ?? "0", 10) + + if (isNaN(page) || page < 0) { + page = 0 + } + if (isNaN(size) || size <= 0) { + return HttpResponse.json( + { message: "Invalid 'size' parameter. It must be a positive integer." }, + { status: 400 }, + ) + } + + const startIndex = page * size + const endIndex = startIndex + size + const result = projects.slice(startIndex, endIndex) + + await delay(1000) + return HttpResponse.json({ + projects: result, + serverTime: Date.now(), + totalPages: Math.ceil(projects.length / size), // totalPages is a parameter required for this example, but an API could include additional fields that can be used in certain scenarios, such as determining getNextPageParam or getPreviousPageParam. + // totalElements: projects.length, + // numberOfElements: result.length, + // isLast: endIndex >= projects.length, + // isFirst: page === 0, + }) + }), ]