1. 라이브러리 설치
npm install react-intersection-observer
npm install @tanstack/react-query @tanstack/react-query-devtools
2. 탠스택쿼리 프로바이더 설정
"use client";
import { isServer, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// SSR을 사용하면 일반적으로 기본 staleTime을
// 0 이상으로 설정하여 클라이언트에서 즉시 리프레시되지 않도록 합니다.
refetchOnWindowFocus: false,
retry: false,
staleTime: 60 * 1000,
},
},
});
}
// undefined 로 초기값 설정
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
// 서버: 항상 새 쿼리 클라이언트 만들기
return makeQueryClient();
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
// 데브툴즈 initialIsOpen 꼭 아래처럼 설정해야 서버 오류 안남
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={process.env.NEXT_PUBLIC_RUN_MODE === "local"} />
</QueryClientProvider>
);
}
export default QueryProvider;
<QueryProvider>{children}</QueryProvider>
3. Route handler 작성
// constants.ts
export const ITEMS_PER_PAGE = 5;
// api/posts
import { ITEMS_PER_PAGE } from "@/constants/constants";
import createClient from "@/supabase/supabaseServer";
import { Post } from "@/types/typs";
import { QueryError } from "@supabase/supabase-js";
import { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const pageString = searchParams.get("page");
if(pageString) {
const supabase = createClient();
const page = Number(pageString);
const start = page * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE - 1;
const { data, error }: { data: Post[]; error: QueryError } =
await supabaseClient
.from("posts")
.select("*")
.order("created_at", { ascending: false }) // 생성일 정렬
.range(start, end); // 데이터 범위 설정
if (error) {
return new Response(JSON.stringify(error.message), { status: 401 });
}
return new Response(JSON.stringify(data), { status : 200 });
}
return new Response(JSON.stringify({data : "파라미터 누락"}, { status : 401 }));
}
4. fetch 함수 작성
// getInfinitePosts.tsx
import { Post } from "@/types/typs";
import { QueryFunctionContext } from "@tanstack/react-query";
export async function getInfinitePosts({
pageParam = 0,
}: QueryFunctionContext<string[], number>): Promise<Place[]> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts?page=${pageParam}`, {
method: "GET",
next: {
tags: ["postsInfinite"],
},
cache: "no-store",
});
if (!response.ok) {
throw new Error("fetch 실패");
}
const data = await response.json();
return data;
}
5. 서버컴포넌트
export default function Home() {
return (
<Suspense fallback={<Loading />}>
<PostListSuspense />
<TopButton />
</Suspense>
);
}
import { getInfinitePosts } from "@/api/getInfinitePosts";
import { Place } from "@/types/typs";
import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query";
export default async function PostListSuspense() {
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ["postsInfinite"],
initialPageParam: 0,
getNextPageParam: (lastPage: Post[], allPages: Post[][]) => {
if (lastPage.length === 0) return null;
return allPages.length;
},
queryFn: getInfinitePosts,
pages: 1, // 설정한 페이지 단위 중 첫 1페이지만 가져옴
});
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>
<Component />
</HydrationBoundary>
);
}
6. 클라이언트 컴포넌트
"use client";
import { getInfinitePosts } from "@/api/getInfinitePosts";
import { Place } from "@/types/typs";
import { useInfiniteQuery } from "@tanstack/react-query";
import InfiniteScroll from "./InfiniteScroll";
import PostCard from "./PostCard";
function 컴포넌트명() {
const {
data: posts = [],
isFetching,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ["postsInfinite"],
initialPageParam: 5,
getNextPageParam: (lastPage: Post[], allPages: Post[][]) => {
if (lastPage.length === 0) return null;
return allPages.length;
},
queryFn: getInfinitePosts,
select: (data) => data.pages.flat(),
});
return (
<InfiniteScroll fetchNextPage={fetchNextPage} hasNextPage={hasNextPage}>
<ul>
{posts.map((post) => post && <PostCard key={post.id} place={post} />)}
{isFetching && <li className="text-center">Loading...</li>}
</ul>
</InfiniteScroll>
);
}
"use client";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
const InfiniteScroll = ({
fetchNextPage,
hasNextPage,
children,
}: {
fetchNextPage: () => void;
hasNextPage: boolean;
children: React.ReactNode;
}) => {
const { ref, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (!(inView && hasNextPage)) return;
fetchNextPage();
}, [inView, hasNextPage, fetchNextPage]);
return (
<>
{children}
<div className="h-[20px]" ref={ref} />
</>
);
};
export default InfiniteScroll;