Next.js와 react-query로 todo앱 만들기 (useQuery)

jonyChoiGenius·2023년 4월 26일
1

React.js 치트 시트

목록 보기
18/22
post-thumbnail

api 만들기

react-query를 사용하려면 서버가 있어야 한다.

서버를 만드는 가장 쉬운 방법은 next.js의 api routes를 이용하는 것이다.

pages\api\todos.ts 에 아래와 같이 임시 api를 만든다.

import { NextApiRequest, NextApiResponse } from "next";

// 임시 데이터
const todos = [
  { id: 1, text: "Todo 1", completed: false },
  { id: 2, text: "Todo 2", completed: true },
  { id: 3, text: "Todo 3", completed: false },
];

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === "GET") {
    const { id } = req.query;
    if (id) {
      const todo = todos.find((todo) => todo.id === Number(id));
      if (!todo) {
        res.status(404).end();
      } else {
        res.status(200).json(todo);
      }
    } else {
      res.status(200).json(todos);
    }
  } else if (req.method === "POST") {
    // POST 요청 처리
    const { text } = req.body;
    const newTodo = { id: todos.length + 1, text, completed: false };
    todos.push(newTodo);
    res.status(201).json(newTodo);
  } else if (req.method === "PUT") {
    // PUT 요청 처리
    const { id, completed } = req.body;
    const todoIndex = todos.findIndex((todo) => todo.id === id);
    if (todoIndex === -1) {
      res.status(404).end();
    } else {
      todos[todoIndex].completed = completed;
      res.status(200).json(todos[todoIndex]);
    }
  } else if (req.method === "DELETE") {
    // DELETE 요청 처리
    const { id } = req.body;
    const todoIndex = todos.findIndex((todo) => todo.id === id);
    if (todoIndex === -1) {
      res.status(404).end();
    } else {
      const deletedTodo = todos.splice(todoIndex, 1)[0];
      res.status(200).json(deletedTodo);
    }
  } else {
    res.status(405).end();
  }
}

해당 API를 사용하는 axios 서비스를 아래와 같이 만들어준다.
services\TodosApi.ts

import axios, { AxiosResponse } from "axios";

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

const TodosApi = axios.create({
  baseURL: "http://localhost:3000/api/todos/",
});

export default TodosApi;

QueryClientProvider

먼저 QueryClient를 만들어줘야 한다.
services\queries.ts 폴더를 아래와 같이 작성해주었다.

import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default queryClient;

이때 query에는 'staleTime'이라는 옵션이 있다. fresh한 데이터가 어느 정도의 시간이 지난 후 stale(신선하지 않아서 못써먹는) 데이터로 바뀌는지에 대한 설정이다. 이 값을 설정하지 않으면 모든 데이터가 stale한 데이터로 인식이 되고, 이는 곧 데이터가 캐시되지 않음을 의미한다.

우리는 캐시되는 데이터를 원하므로 state 데이터의 기본 값을 넣어주자.

import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
    },
  },
});

export default queryClient;

그리고 QueryClientProvider를 pages_app.tsx에 적용시킨다

import type { AppProps } from "next/app";
import { QueryClientProvider } from "@tanstack/react-query";
import queryClient from "../store/queries";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
        <Component {...pageProps} />
    </QueryClientProvider>
  );
}

쿼리키를 따로 관리하기

services\queryKeys.ts에 쿼리 키를 하나 만들어준다.

export const ListTodoQueryKey = () => ["todo-list"];

함수 형태로 선언하는 이유는 파라미터를 받아서 변형되는 쿼리키를 만들기 위함이다.
파라미터를 받아 해당 쿼리키를 배열에 포함한 쿼리키는 아래와 같다.

export const RetrieveTodoQueryKey = (todoId: number) => [
  ...ListTodoQueryKey(),
  todoId,
];

...ListTodoQueryKey()와 같은 형태로 다른 쿼리키의 평가값을 spread 하는 방식으로 상하관계를 표현하고자 했다.

팩토리 구조로 쿼리키 관리하기

쿼리키를 생성하는 팩토리 구조를 만들어두면 불러오기 편하다. 또한 RetrieveTodoQueryKey와 같이 이름이 무분별하게 길어지는 것도 방지하고, 계층 구조를 표현할 수 있는 장점이 있다.

services\index.ts에 Keys라는 팩토리를 선언해서 아래와 같이 정의해줬다.

export const Keys = {
  todoKeys: {
    list: () => ["todo-list"] as const,
    retrieve: (TodoId: number) => [...Keys.todoKeys.list(), TodoId] as const,
  },
};

타입을 따로 관리하기

types\todos.d.ts 를 만들어 아래와 같이 타입을 선언한다.

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

클릭했을 때 리스트를 불러오기

리스트를 불러오는 axios API를 만든다.
services\TodosApi.ts

export const listTodos = () =>
  TodosApi.get("").then((res) => {
    console.log(res);
    return res as AxiosResponse<Array<Todo>>;
  });

pages\queryTodos.tsx에 해당 쿼리키를 이용한 쿼리를 만들어준다.

  const {
    data: TodoListData,
    isLoading: TodoListIsLoading,
    fetchStatus,
    status,
    refetch,
  } = useQuery(ListTodoQueryKey(), listTodos, {
    staleTime: 3 * 60 * 1000,
    enabled: false,
  });

이를 실행하는 버튼을 만들어준다.

      <div
        onClick={() => {
          refetch();
        }}
      >
        페치하기
      </div>

데이터가 불러와지면 TodoListData에 값이 저장된다.
이를 이용하여 아래와 같이 렌더링한다.

      {TodoListData &&
        TodoListData.data.map((e) => {
          return (
            <div onClick={() => setSelectedId(e.id)}>
              <div key={e.id}>
                {e.id} / {e.text} / {e.completed.toString()}
              </div>
            </div>
          );
        })}

Params를 받아 데이터를 불러오기

하나의 데이터만 받아오고 싶을 땐 어떻게 할까?

먼저 params를 새로운 state로 선언한다.

  const [selectedId, setSelectedId] = useState(0);

해당 state를 참조하는 쿼리를 만들면 된다.

  const [selectedId, setSelectedId] = useState(0);
  const { data: TodoData, isLoading: TodoIsLoading } = useQuery(
    RetrieveTodoQueryKey(selectedId),
    () => retrieveTodos(selectedId),
    {
      staleTime: 3 * 60 * 1000,
      keepPreviousData: true,
    },
  );

이때 TodoData가 깜빡이는 것을 방지하기 위해 keepPreviousData: true 옵션을 넣어주었다. onClick={() => setSelectedId(e.id)}를 이용하여 selectedId를 바꾸면, 데이터가 바뀐다.

      {TodoData ? (
        <div>
          <div>선택된 데이터</div>
          <div>{TodoData.data.text}</div>
          <div>{TodoData.data.completed}</div>
        </div>
      ) : (
        <></>
      )}

Query들을 따로 관리하기 (feat.CustomHooks)

쿼리를 하나의 폴더로 관리하기 위해 두 가지의 원칙을 세웠다.

  1. API와 쿼리를 통합하기
    하나의 API에 여러개의 Query가 나올 수 있으므로 API와 Query를 따로 분리하는 것은 적절하다.
    하지만, 대체로 하나의 API에 하나의 Query가 나오는 경우가 잦으며, 이를 통해 특정 API의 상태를 하나로 관리한다는 점에서 이점이 있다.

  2. 쿼리를 커스텀 훅으로 호출하기
    useQuery는 훅이다. 훅은 컴포넌트 내에서 호출되고 실행되어야 한다.
    그리고 useQuery가 객체를 반환하는 관계로 이를 구조분해할당 하는 과정에서 컴포넌트 내부의 코드가 지저분해지는 문제가 있다.
    const {data: TodoData} = useQuery(["todo-list"], queryFn)
    만일 useQuery가 배열 형태로 값을 반환한다면 아래와 같이 받을 수 있을 것이다.
    const [TodoData] = useCustomQuery(["todo-list"], queryFn)

배열 형태로 반환하는 커스텀 훅을 만드는 과정은 아래와 같이 진행된다.

타입을 선언하기

types\query.d.ts

export type CustomQueryHookReturnType<TData = any> = [
  TData,
  <TPageData>(
    options?: RefetchOptions & RefetchQueryFilters<TPageData>,
  ) => Promise<QueryObserverResult<AxiosResponse<any, any>, unknown>>,
  UseQueryResult<TData, TError>,
];

export type CustomQueryHookType<TParams = unknown, TData = any> = (
  params?: TParams,
  options?: Omit<
    UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    "queryKey" | "queryFn" | "initialData"
  > & { initialData?: () => undefined },
) => CustomQueryHookReturnType<TData>;

간단히 설명하면, CustomQueryHookType은 커스텀 훅에 사용될 타입이다.
타입을 선언하면서 Params와 반환할 Data의 타입을 선언할 수 있도록 TParams, TData라는 타입을 추가로 지정해주었다.

커스텀 훅을 만들기

services\todos\index.ts 에 아래와 같이 작성해준다.

export const useListTodoQuery: CustomQueryHookType<null, Array<Todo>> = (
  _,
  options = {},
) => {
  //API 호출하는 함수
  const listTodos = () => TodosApi.get("");
	
  const query = useQuery(QueryKeys.todoKeys.list(), () => listTodos(), options);
  return [query.data.data, query.refetch, query];
};

위의 쿼리는 배열 형태로 데이터, refetch 메서드, 쿼리 전체를 반환한다.

위의 커스텀 훅을 컴포넌트에서 사용할 때에는 아래와 같이 사용하면 된다.

const [todoListData, todoListDataRefetch] = useListTodoQuery();
단 한 줄로 깔끔하게 끝난다.

useListTodoQuery함수를 찾기 쉽도록 아래와 같이 인덱싱도 해준다.

const todosQuery = {
  useListTodoQuery,
  useRetrieveTodoQuery,
};

export default todosQuery;

리팩토링

export const useListTodoQuery: CustomQueryHookType<null, Array<Todo>> = (
  _,
  options = {},
) => {
  //API 호출하는 함수
  const listTodos = () => TodosApi.get("");
	
  const query = useQuery(QueryKeys.todoKeys.list(), () => listTodos(), options);
  return [query.data.data, query.refetch, query];
};

해당 코드에서 queryKey 부분과 queryFn 부분을 별개로 분리해낸다.

export const useListTodoQuery: CustomQueryHookType<null, Array<Todo>> = (
  _,
  options = {},
) => {
  const queryKey = QueryKeys.todoKeys.list();
  const getQueryFn = () => {
    return () => TodosApi.get("");
  };
  
  const query = useQuery(queryKey, getQueryFn(), options);
  return [query.data.data, query.refetch, query];
};

queryKey와 getQueryFn으로 분리하니 훨씬 가독성이 좋아졌다.

이렇게 분리하고 나니

  const query = useQuery(queryKey, getQueryFn(), options);
  return [query.data.data, query.refetch, query];

위 부분이 반복됨이 보인다.
이 부분도 별도의 함수로 분리하자.

services\index.ts 에 아래와 같은 함수를 만들었다.

export const getQueryResult = (
  queryKey: QueryKey,
  queryFn: () => Promise<AxiosResponse<any, any>>,
  options?: UseQueryOptionsType,
): CustomQueryHookReturnType => {
  const query = useQuery(queryKey, queryFn, options);
  return [query.data.data, query.refetch, query];
};

이때 useQueryOptionsType은 useQuery에 넘겨주는 옵션에 대한 타입으로 아래와 같다.

export type UseQueryOptionsType = Omit<
  UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
  "initialData" | "queryFn" | "queryKey"
> & {
  initialData?: () => undefined;
};

결과적으로 services\todos\index.ts의 코드는 아래와 같다.

export const listTodoQuery: CustomQueryHookType<null, Array<Todo>> = (
  _,
  options = {},
) => {
  const queryKey = QueryKeys.todos.list();
  const getQueryFn = () => {
    return () => TodosApi.get("");
  };

  return getQueryResult(queryKey, getQueryFn(), options);
};

export const retrieveTodoQuery: CustomQueryHookType<number, Todo> = (
  selectedId,
  options = {},
) => {
  const queryKey = QueryKeys.todos.retrieve(selectedId);
  const getQueryFn = (id: number) => {
    return () => TodosApi.get(`?id=${id}`);
  };

  return getQueryResult(queryKey, getQueryFn(selectedId), options);
};

커스텀 훅을 사용하기

해당 커스텀 훅 listTodoQuery와 retrieveTodoQuery를 사용하는 방법은 아래와 같다.

  const [todoListData, todoListDataRefetch] = listTodoQuery();

  const [selectedId, setSelectedId] = useState(0);
  const [TodoData] = retrieveTodoQuery(selectedId, {
    keepPreviousData: true,
  });

매우 짧고 간결해졌다. 심지어 옵션을 쓰지 않으면 1-2줄 만에도 상태를 불러올 수 있다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글