Frontend/Today I Learned

[Next.js] TanStack Query로 count up/down 기능 구현하기

joycie416 2024. 10. 1. 23:52

우리는 외부에서 데이터를 가져와서 사용해야 하기 때문에 이를 위한 관리 도구가 필요하다. 이전에 다루었던 것처럼 이번에도 역시 TanStack Query(React Query)를 사용할 것이다.

 

참고로 Next.js에서는 `fetch`를 최적화해서 제공하기 때문에 `axios`를 사용하지 않고 `fetch`를 사용할 것이다.

 

세팅 : app route 사용, src 폴더 사용

 

1. `server-action.ts` 만들기

먼저 server component를 만들었던 것처럼 파일 맨 상단에  `"use server"`를 추가하자. 그러면 해당 `server action` 파일에 비동기 함수를 모아 api처럼 활용할 수 있다. 이러한 `server-action.ts` 파일은 서버 측에서 비동기 함수를 실행할 수 있게 해준다.

 

`src` 폴더에 `server-action.ts`를 만들고 데이터를 불러오는 함수를 만들자.

// ./db.json
{
  "db": [
    {
      "id": "1",
      "count": 0
    }
  ]
}

// ./src/types/type.ts
export type DB = { count: number };
// ./src/server-action.ts
"use server";

import { DB } from "./types/type";

const BASE_URL = "http://localhost:5000";

export async function getData(): Promise<DB> {
  const res = await fetch(BASE_URL + "/db", {
    cache: 'no-store'
  });
  const data: DB[] = await res.json();
  return data[0];
}

export async function update(count: number): Promise<void> {
  const res = await fetch(BASE_URL + "/db/1", {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({count}),
    cache: 'no-store',
  });
}

 

여기서 `db.json`을 살펴보면 불필요해보이는 `id`가 있는 것을 알 수 있는데,  `json-server` docs에 의해 `PATCH`는 `id`를 필요로하기 때문이다. 그리고 맨 처음에 `db`를 배열로 만들지 않고 `{"count": 0`}`이라고 했다가 데이터가 이상하게 저장되는 문제를 겪었다.

 

2. 데이터 불러오기 : `useQuery` 사용하기

// ./src/providers/TQProvider.tsx
"use client";

// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
// ./src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import QCProvider from "@/components/providers/TQProvider";
import { DB } from "@/types/type";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const res = await fetch("http://localhost:5000/db");
  const data: DB[] = await res.json();

  return (
    <html lang="en">
      <body>
        <QCProvider>
          {children}
        </QCProvider>
      </body>
    </html>
  );
}
// ./src/components/Display.tsx
"use client";

import { getData } from "@/server-action";
import { DB } from "@/types/type";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

const Display = () => {
  const queryClient = useQueryClient();

  const {
    data = {count},
    isLoading,
    isPending,
  }: { data: DB | undefined; isLoading: Boolean; isPending: Boolean } =
    useQuery({
      queryKey: ["count"],
      queryFn: () => getData(),
    });

  const { mutate } = useMutation({
    mutationFn: update,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["count"],
      });
    },
  });

  if (isLoading || isPending) return <>loading.....</>;

  return (
    <div className="flex flex-col justify-center items-center">
      <p>
        Count : <span>{data?.count}</span>
      </p>
      <div className="flex gap-4">
        <button
          className="px-5 py-2 bg-gray-300 rounded-md"
          onClick={() => {
            mutate(data.count + 1);
          }}
        >
          +1
        </button>
        <button
          className="px-5 py-2 bg-gray-300 rounded-md"
          onClick={() => {
            mutate(data.count - 1);
          }}
        >
          -1
        </button>
        <button
          className="px-5 py-2 bg-gray-300 rounded-md"
          onClick={() => {
            mutate(0);
          }}
        >
          초기화
        </button>
      </div>
    </div>
  );
};

export default Display;

 

여기서 주목해야할 부분은 `queryFn`부분이다. TypeScript를 사용하지 않았을 때에는 `getData`만 적었을텐데(이와 같은 방법을 refernce calling이라고 한다) TS를 사용할 때에는 함수로 실행시켜줘야 한다. 따라서 `queryFn: () => getData()`로 적어야 한다.

 

TanStack Query Docs를 확인해보면 data fetching 함수들이 대부분 return type이 `any`라서 type을 명확히 하기 위함이라고 한다. 원래 이부분도 `queryFn: getData`이라고 했다가 데이터 로딩이 너무 느리고, `invalidateQueries`가 제대로 작동하지 않는 문제를 겪었다.

 

3. `prefetchQuery` 사용하기

위 `Display.tsx`는 `"use client"`가 적혀있는 것을 보면, client component인 것을 알 수 있다. 따라서 브라우저에서 렌더링될 때 데이터를 불러오게 된다. 그러면 데이터가 많은 경우 로딩이 길어져 TTV(Time-To-View)가 저해된다. 따라서 server component에서 미리 데이터를 불러와서 전달해주는 것이 좋을 것이다.

 

이를 위해서 TanStack Query를 사용해 데이터를 prefetchg할 수 있는 방법이 있다.

// ./src/app/page.tsx

import Display from "@/components/Display";
import { getData } from "@/server-action";
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";

export default async function Home() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000,
      },
    },
  });

  await queryClient.prefetchQuery({
    queryKey: ["count"],
    queryFn: () => getData(),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className='flex flex-col justify-center items-center'>
        <h3>Counting</h3>
        <Display />
      </div>
    </HydrationBoundary>
  );
}

 

위와 같이 `Display.tsx`의 상위 컴포넌트에서 같은 `queryKey`를 갖도록 prefetch해주고 하위 컴포넌트를 `HydrationBoundary`로 감싸주면 된다. 여기서도 `quefyFn`이 함수 형태로 작성되었음을 확인하자.

 


이번에는 굉장히 사소한 부분에서 에러를 많이 겪어서 스스로 발견하기가 너무 어려웠다. 이번 기회로 머리에 잘 넣어 둘 수 있게 되었다!