Next JS에서 react-query 적용기 1탄.

1

nextjs

목록 보기
2/2

Next JS에서 react-query 적용기 1탄.

'NextJS에서 별도의 상태관리가 필요한 가?'에 대한 고민이 계속 됐다.

그러던 중 회사 프로젝트에서 유지보수 해야할 업무(외주에서 진행하던)를 맡았고, 코드를 보는데 NextJS에서 React-query를 사용하고 있었다.

그동안 상태관리는 Redux, Recoil, jotai 정도만 접해봤고, react-query를 써볼 기회가 없었다(취업한 후에는 내 의지가 아니라고 할 수 있다. 하지만 사이드 프로젝트에서도 안써본 거는 내 의지 이긴하지만 그냥 내탓이 아니었으면 좋겠다는 변명....)

어쨌든 해당 업무 말고 원래 진행중이던 NextJS 프로젝트에서도 React-query를 사용해볼 겸, 유지보수 해야 할 코드도 볼 겸 공부를 시작한다.

오늘은 설치부터 필요한 각종 메서드와 옵션을 공부할 것이다! 뚜둔(강한 의지)

NextJS + app route에 react-query 더하기!

설치

npm install @tanstack/react-query

React Query 설정

  • app/layout.tsx
    참고) 반드시 여기에 이 경로에 하라는 것은 아님 주의! 다른 경로의 layout.tsx에 하면 경로 이하에 전역적으로 상태관리가 가능해 지는 것임!
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                <QueryClientProvider client={queryClient}>
                    {children}
                </QueryClientProvider>
            </body>
        </html>
    );
}
  • queryClient / queryClientProvider 설정
const queryClient = new QueryClient();

-QueryClient 인스턴스는 모든 쿼리와 뮤테이션의 상태를 관리하며, 캐시와 관련된 설정을 포함한다.
-애플리케이션에서 데이터를 가져오고, 캐시를 관리하고, 쿼리의 생명주기를 제어하는 데 사용

<QueryClientProvider client={queryClient}>

-QueryClientProvider는 React Context API를 사용하여 애플리케이션의 하위 컴포넌트에 queryClient 인스턴스를 제공
-이 컴포넌트로 감싸진 모든 하위 컴포넌트는 useQuery, useMutation 등과 같은 React Query 훅을 사용할 수 있다.

데이터 페칭 함수 만들기(예시)

  • app/api/posts.ts
export const fetchPosts = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!response.ok) {
        throw new Error('Network response was not ok');
    }
    return response.json();
};

React Query로 데이터 가져오기

import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/posts';

const PostsPage = () => {
    const { data, error, isLoading } = useQuery(['posts'], fetchPosts);

    if (isLoading) {
        return <div>Loading...</div>;
    }

    if (error instanceof Error) {
        return <div>Error: {error.message}</div>;
    }

    return (
        <div>
            <h1>Posts</h1>
            <ul>
                {data.map((post: { id: number; title: string }) => (
                    <li key={post.id}>{post.title}</li>
                ))}
            </ul>
        </div>
    );
};

export default PostsPage;

그래서?

첫번째, 메서드와 그 메서드의 사용방법을 먼저 확인해보자.

1. useQuery(서버에서 데이터를 가져오는 가장 기본적인 훅)

import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/posts';

const Posts = () => {
    const { data, error, isLoading } = useQuery(['posts'], fetchPosts);

    if (isLoading) return <div>Loading...</div>;
    if (error instanceof Error) return <div>Error: {error.message}</div>;

    return (
        <ul>
            {data.map((post: { id: number; title: string }) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
};
  • 첫번째 매개변수 query key(고유식별자)
  • 두번째 매개변수 쿼리함수(React Query가 호출하여 데이터를 가져오고, 성공적으로 데이터를 가져오면 해당 데이터를 캐시에 저장)
  • 그러니깐 쿼리함수를 통해 받아온 데이터를 쿼리 키를 통해 구분하겠다는 의미

2. useMutation(서버에 데이터를 추가, 업데이트 또는 삭제)

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPost } from '../api/posts';

const CreatePost = () => {
    const queryClient = useQueryClient();
    const mutation = useMutation(createPost, {
        onSuccess: () => {
            // 데이터가 성공적으로 추가된 후 쿼리 재요청
            queryClient.invalidateQueries(['posts']);
        },
    });

    const handleSubmit = async (event: React.FormEvent) => {
        event.preventDefault();
        const data = { title: event.currentTarget.title.value };
        mutation.mutate(data);
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="title" required />
            <button type="submit">Create Post</button>
        </form>
    );
};
  • 매개변수(ex. createPost) 서버에 데이터를 추가, 수정 또는 삭제하는 비동기 작업을 정의
    cf ) createPost 함수
export const createPost = async (newPost: { title: string }) => {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(newPost),
    });

    if (!response.ok) {
        throw new Error('Network response was not ok');
    }

    return response.json(); // 서버에서 반환된 새 게시물 데이터
};

useMutation의 옵션

  • onSuccess: 뮤테이션이 성공적으로 완료되었을 때 호출되는 콜백 함수
  • onError: 뮤테이션이 실패했을 때 호출되는 콜백 함수
  • onSettled: 뮤테이션이 성공하거나 실패한 후 호출되는 콜백 함수
  • onMutate: 뮤테이션이 시작되기 전에 호출되는 콜백 함수
  • 그러니깐, try(onSuccess), catch(onError), finally(onSettled)의 역할을 한다고 이해하면 됨!

사용예시

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPost } from '../api/posts';

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

    const mutation = useMutation(createPost, {
        onMutate: () => {
            // 뮤테이션이 시작되기 전에 호출
            console.log('Mutation started');
        },
        onSuccess: (data) => {
            console.log('Post created successfully!', data);
            queryClient.invalidateQueries(['posts']);
        },
        onError: (error) => {
            console.error('Error creating post:', error);
        },
        onSettled: () => {
            // 성공 여부와 관계없이 호출
            console.log('Mutation settled');
        },
    });

    const handleSubmit = async (event: React.FormEvent) => {
        event.preventDefault();
        const data = { title: event.currentTarget.title.value };
        //currentTarget: 이벤트가 바인딩된 요소를 참조, 이벤트가 발생한 폼 요소 자체를 가리
        // const data = { title: event.target.title.value };
        // event.target: 이벤트가 실제로 발생한 요소, 일반적으로 currentTarget과 같지만, 
        // 이벤트가 버블링되거나 캡처링되는 경우에는 다를 수 있음
        mutation.mutate(data); // 뮤테이션 실행
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="title" required />
            <button type="submit">Create Post</button>
        </form>
    );
};

어? useQueryClient는 뭔데?

3. useQueryClient(QueryClient 인스턴스에 접근할 수 있는 훅)

  • 데이터 무효화, 재요청 등의 작업
import { useQueryClient } from '@tanstack/react-query';

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

    const handleRefresh = () => {
        queryClient.invalidateQueries(['posts']);
    };

    return <button onClick={handleRefresh}>Refresh Posts</button>;
};
  • 그래가지고 위 예시에서는 뮤테이션 작업이 성공적으로 완료된 후, 기존의 게시물 데이터를 새로 갱신하기 위해 useQueryClient의 invalidateQueries 메소드를 사용(새로운 게시물이 추가된 후, UI에서 항상 최신 상태의 데이터를 보여주기 위해 쿼리를 무효화)

4. useInfiniteQuery(무한 스크롤을 구현)

  • 페이지네이션된 데이터를 가져오는 데 유용
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/posts';

const Posts = () => {
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isLoading,
    } = useInfiniteQuery(['posts'], fetchPosts, {
        getNextPageParam: (lastPage) => lastPage.nextCursor,
    });

    return (
        <div>
            {isLoading && <div>Loading...</div>}
            {data?.pages.map((page) =>
                page.posts.map((post) => <div key={post.id}>{post.title}</div>)
            )}
            {hasNextPage && <button onClick={fetchNextPage}>Load More</button>}
        </div>
    );
};
  • 약간 useNavigation의 느낌이랄까? 그래서 해당 매개변수를 알아두면 유용할 것 같다.

useInfiniteQuery의 매개변수

  • getNextPageParam: 다음 페이지를 요청하는 데 필요한 매개변수를 정의
getNextPageParam: (lastPage, pages) => {
    return lastPage.nextCursor; // lastPage에서 다음 커서를 가져옴
}
  • getPreviousPageParam: 이전 페이지를 요청하는 데 필요한 매개변수를 정의
getPreviousPageParam: (firstPage, pages) => {
    return firstPage.previousCursor; // 첫 페이지에서 이전 커서를 가져옴
}
  • staleTime: 쿼리 데이터가 신선하다고 간주되는 시간(밀리초). 이 시간 동안 쿼리는 재요청되지 않음
  • cacheTime: 쿼리 데이터가 캐시에 남아 있는 시간(밀리초). 이 시간이 지나면 데이터는 garbage collection에 의해 제거
  • refetchOnWindowFocus: 브라우저 창이 포커스를 받을 때 쿼리를 자동으로 다시 요청할지를 결정
  • refetchOnReconnect: 네트워크가 다시 연결될 때 쿼리를 자동으로 다시 요청할지를 결정
  • enabled: 쿼리의 활성화 여부를 결정하는 불리언 값. true일 경우에만 쿼리가 실행

너무 많아서 이쯤에서 포기할까? 라는 생각 한 번 정도 해줘야함

5. useIsFetching(현재 쿼리가 진행 중인지 여부를 확인)

import { useIsFetching } from '@tanstack/react-query';

const LoadingIndicator = () => {
    const isFetching = useIsFetching();

    return isFetching ? <div>Loading...</div> : null;
};
  • 전역 로딩 인디케이터, 조건부 렌더링, 상태 기반 스타일링 등이 필요할 때 주로 사용

6. useHydrate(서버 사이드 렌더링(SSR)과 클라이언트 사이드에서의 데이터 동기화를 도와주는 훅)

import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';

const App = ({ dehydratedState }) => {
    const queryClient = new QueryClient();
    
    return (
        <QueryClientProvider client={queryClient}>
            <Hydrate state={dehydratedState}>
                {/* Your application components */}
            </Hydrate>
        </QueryClientProvider>
    );
};
  • Hydrate 컴포넌트는 서버에서 미리 가져온 데이터를 클라이언트의 캐시에 주입하는 역할
  • 서버 사이드 렌더링(SSR) 또는 정적 사이트 생성(SSG) 시, 서버에서 미리 데이터를 가져와 클라이언트에서 다시 요청하지 않고도 렌더링할 수 있음

중요한건 꺾이지 않는 마음이 아니라, 꺾여도 하는 마음이라고 누가 그랬는데 뭔소리야.

이미 하기 싫어 죽겠어가지고 아니 NextJS는 기본적으로 SSR이잖아? 그럼 useHydrate는 없어도 되는거 아녔어???라고 하나 정도 줄여보려고 했는데 아님.

나, NextJS 마스터 다! 손. 그럼 이 부분은 넘기셔도 됨

서버 사이드 렌더링(SSR) 예시

// pages/index.js
import { Hydrate, QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';

const fetchPosts = async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!res.ok) throw new Error('Network response was not ok');
    return res.json();
};

const PostsList = () => {
    const { data, error, isLoading } = useQuery('posts', fetchPosts);

    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return (
        <ul>
            {data.map((post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
};

const App = ({ dehydratedState }) => {
    const queryClient = new QueryClient();

    return (
        <QueryClientProvider client={queryClient}>
            <Hydrate state={dehydratedState}>
                <PostsList />
            </Hydrate>
        </QueryClientProvider>
    );
};

// 서버 사이드 렌더링을 위한 getServerSideProps
export async function getServerSideProps() {
    const queryClient = new QueryClient();

    // 데이터 미리 가져오기
    await queryClient.prefetchQuery('posts', fetchPosts);

    return {
        props: {
            dehydratedState: dehydrate(queryClient), // hydrate에 사용할 상태
        },
    };
}

export default App;

서버 사이드 렌더링을 위한 getServerSideProps

  • getServerSideProps는 Next.js에서 서버 사이드 렌더링(SSR)을 구현하기 위한 함수
  • 페이지 요청 시 서버에서 실행되며, 데이터를 미리 가져와서 페이지를 렌더링하는 데 필요한 props를 반환

정적 사이트 생성(SSG) 예시

// pages/index.js
import { Hydrate, QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { dehydrate } from '@tanstack/react-query';

const fetchPosts = async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!res.ok) throw new Error('Network response was not ok');
    return res.json();
};

const PostsList = () => {
    const { data, error, isLoading } = useQuery('posts', fetchPosts);

    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return (
        <ul>
            {data.map((post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
};

const App = ({ dehydratedState }) => {
    const queryClient = new QueryClient();

    return (
        <QueryClientProvider client={queryClient}>
            <Hydrate state={dehydratedState}>
                <PostsList />
            </Hydrate>
        </QueryClientProvider>
    );
};

// 정적 사이트 생성을 위한 getStaticProps
export async function getStaticProps() {
    const queryClient = new QueryClient();

    // 데이터 미리 가져오기
    await queryClient.prefetchQuery('posts', fetchPosts);

    return {
        props: {
            dehydratedState: dehydrate(queryClient), // hydrate에 사용할 상태
        },
        revalidate: 10, // 10초마다 재생성
    };
}

export default App;

이 부분에 대해서는 따로 정리한 것이 있음. 아직 업로드를 하지 않은 것임. 그래서 그냥 이렇게 쓰이기 때문에 useHydrate가 NextJS에서도 사용되는거구나 정도로만 이해하고 넘어가세오.

혹시 눈치 챈 사람 있나?
지나오면서 QueryClient 설명을 했을때, invalidateQueries 메서드를 사용했다는 부분.

invalidateQueries의 주요 메서드(이것또 정리했찌 이게 바로 나.)

fetchQuery

const data = await queryClient.fetchQuery('posts', fetchPosts);
  • 지정한 쿼리 키와 쿼리 함수를 사용하여 데이터를 즉시 가져옴
  • 쿼리를 실행하고, 결과를 반환
  • 쿼리는 캐시되지 않으며, 항상 최신 데이터를 가져옴

prefetchQuery

await queryClient.prefetchQuery('posts', fetchPosts);
  • prefetchQuery는 지정한 쿼리 키와 쿼리 함수를 사용하여 데이터를 미리 가져옴
  • 미리 가져온 데이터는 캐시에 저장
  • 데이터를 캐시에 저장하므로, 다음 번에 해당 쿼리를 요청할 때 캐시된 데이터를 즉시 사용가능

invalidateQueries

queryClient.invalidateQueries('posts');
  • invalidateQueries는 지정한 쿼리 키에 대한 캐시를 무효화
  • 다음 렌더링 시 해당 쿼리를 다시 요청

setQueryData

queryClient.setQueryData('posts', newData);
  • setQueryData는 특정 쿼리 키에 대한 데이터를 수동으로 설정
  • 캐시된 데이터를 업데이트할 때 사용
  • 새로운 데이터로 기존 데이터를 대체

자, 이제 이 아니라. 시작임 아직 아무것도 하지 않았음. 나는 메서드만 봤을 뿐임. 하지만 내 맘은 아까 꺾임.
그렇지만 또 안할 수 없는 현실이 싫을 뿐 다음에는 작은 예시 혹은 프로젝트에 직접 적용해보겠씀. 이만 춍춍

profile
`나는 ${job} 개발자`

1개의 댓글

comment-user-thumbnail
4일 전

프론트라 잘 모르지만 개 쩌는군…

답글 달기