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)
쿼리를 하나의 폴더로 관리하기 위해 두 가지의 원칙을 세웠다.
-
API와 쿼리를 통합하기
하나의 API에 여러개의 Query가 나올 수 있으므로 API와 Query를 따로 분리하는 것은 적절하다.
하지만, 대체로 하나의 API에 하나의 Query가 나오는 경우가 잦으며, 이를 통해 특정 API의 상태를 하나로 관리한다는 점에서 이점이 있다. -
쿼리를 커스텀 훅으로 호출하기
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줄 만에도 상태를 불러올 수 있다.