React Query는 데이터 Fetching, 캐싱, 동기화, 서버 쪽 데이터 업데이트 등을 쉽게 만들어 주는 React 라이브러리입니다.
그렇다면 사용하는 이유는 무엇일까요 ?
먼저 프론트엔드 개발을 하는 사람들이 가장 많이 하는 고민중에 하나는 바로 '상태관리' 입니다. 프론트엔드 개발자라면 상태관리와 뗄 수 없는 인연을 가지고 있습니다. 그리고 많은 사람들이 상태관리를 위해 Redux를 사용합니다.
그리고 Redux를 이용하여 서버 데이터를 활용하기 위해서는 Redux-saga와 같은 다른 미들웨어를 사용해야 합니다. 하지만 프로젝트를 계속 진행하면서 API가 계속 추가되고 API마다 액션과 액션 타입, Saga 파일 등으로 인해 프로젝트의 구성이 복잡해지는 문제성을 갖고 있습니다.
이 뿐만 아니라 서버로 부터 값을 가져오거나 업데이트 하는 로직을 store 내부에 개발하는 경우가 많습니다. 그렇다보니 store는 클라이언트 state를 유지해야하는데 어느 순간부터 store에 클라이언트 데이터와 서버 데이터가 공존 하게 됩니다.
서버 데이터를 위한 로직이 과도하게 커지고, 그로 인해서 Redux 를 활용하기 위한 보일러 플레이트가 비대해 진다는 점이 문제점이 됩니다.
그래서 React Query를 활용해 클라이언트와 서버의 데이터를 분리하여 사용합니다.
$ yarn add react-query
$ npm i react-query
먼저 react의 가장 기본이 되는 곳에 react-query를 사용하도록 세팅합니다.
/* index.js */
import { QueryClient, QueryClientProvider } from 'react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from "react-query";
import { getTodos, postTodo } from "../my-api";
// Create a client
const queryClient = new QueryClient();
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
);
}
function Todos() {
// Access the client
const queryClient = useQueryClient();
// Queries
const query = useQuery("todos", getTodos);
// Mutations
const mutation = useMutation(postTodo, {
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries("todos");
},
});
return (
<div>
<ul>
{query.data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button
onClick={() => {
mutation.mutate({
id: Date.now(),
title: "Do Laundry",
});
}}
>
Add Todo
</button>
</div>
);
}
render(<App />, document.getElementById('root'))
위의 예제는 공식문서에 나와있는 예제입니다. 이 예제는 react-query의 3가지 중요한 컨셉을 보여줍니다.
import { useQuery } from 'react-query'
function App() {
const info = useQuery('todos', fetchTodoList, options)
}
먼저 Queries의 사용법은 위와 같습니다.
useQuery는 서버에서 데이터를 가져오기(get) 위해 사용하는 hook 입니다. unique key, promise 기반의 함수, 옵션 값을 파라미터로 받아서 동작합니다. unique key는 애플리케이션 전역에서 해당 쿼리를 refetching, caching, sharing 하는 용도로 사용되며, 쿼리의 리턴 값으로는 status, data, error와 같은 템플릿을 포함하여 데이터 사용에 필요한 정보가 제공됩니다.
const {
isSuccess,
isError,
isLoading,
isFetching,
data,
error
} = useQuery(
'todos',
fetchTodoList,
{
onSuccess: (data) => {
console.log('onSuccess', data);
},
onError: (error) => {
console.log('onError', error);
}
}
);
if (isFetching) {
console.log('fetching...');
}
if (isLoading) {
console.log('loading...');
}
if (isError) {
console.log('error', error);
}
if (isSuccess) {
console.log('success', data);
}
위와 같이 상태값에 따라 다른 로직을 실행하도록 useQuery를 활용할 수 있으며 isLoading, isError, isSuccess는 status로 통일해서 사용할 수 있습니다.
if (status === 'loading') {
console.log('loading...');
}
if (status === 'error') {
console.log('error', error);
}
if (status === 'success') {
console.log('success', data);
}
// A list of todos
useQuery('todos', ...) // queryKey === ['todos']
// Something else, whatever!
useQuery('somethingSpecial', ...) // queryKey === ['somethingSpecial']
✔️ Query key가 배열일 때
// An individual todo
useQuery(['todo', 5], ...)
// queryKey === ['todo', 5]
// An individual todo in a "preview" format
useQuery(['todo', 5, { preview: true }], ...)
// queryKey === ['todo', 5, { preview: true }]
// A list of todos that are "done"
useQuery(['todos', { type: 'done' }], ...)
// queryKey === ['todos', { type: 'done' }]
✔️ Query key에 있는 object들의 순서는 중요하지 않다.
동일한 Query key -> array의 object내 에 있는 값들의 순서는 중요하지 않음 (동일함)
useQuery(['todos', { status, page }], ...)
useQuery(['todos', { page, status }], ...)
useQuery(['todos', { page, status, other: undefined }], ...)
//동일 하지 않은 Query key. -> array 값의 순서는 중요하다
useQuery(['todos', status, page], ...)
useQuery(['todos', page, status], ...)
useQuery(['todos', undefined, page, status], ...)
useQuery(['todos'], fetchAllTodos)
useQuery(['todos', todoId], () => fetchTodoById(todoId))
useQuery(['todos', todoId], async () => {
const data = await fetchTodoById(todoId)
return data
})
useQuery(['todos', todoId], ({ queryKey }) => fetchTodoById(queryKey[1]))
아래의 코드는 에러를 핸들링해서 throw해주는 예제이다.
const { error } = useQuery(['todos', todoId], async () => {
if (somethingGoesWrong) {
throw new Error('Oh no!')
}
return data
})
useQuery는 기본적으로 비동기로 동작합니다. useQuery에 다음과 같이 enabled 옵션을 false로 사용하면 동기적으로 사용할 수 있습니다.
enabled 옵션을 false로 사용하게 되면 컴포넌트가 mount 되거나 window focus 되어도 쿼리가 자동으로 실행되지 않습니다. 또한 queryClient에서 invalidateQueries 또는 refetchQueries 함수를 호출해도 refetching 되지 않습니다. 쿼리는 캐싱되지 않은 idle 상태이며 fetching을 위해서는 refetch 함수를 트리거로 사용해야 합니다.
const {
isSuccess,
isError,
isLoading,
isFetching,
data,
error
} = useQuery(
'todos',
fetchTodoList,
{
enabled: false,
onSuccess: (data) => {
console.log('onSuccess', data);
},
onError: (error) => {
console.log('onError', error);
}
}
);
useQuery는 기본적으로 비동기로 동작하기 때문에 컴포넌트 내에 useQuery가 여러 개 있다면 순서대로 실행되지 않고 동시에 실행됩니다.
이러한 경우에 다음과 같이 useQueries를 이용하면 여러개의 쿼리를 하나로 묶어서 사용할 수 있습니다. 아래 예제를 실행하면 UseQueryResult가 배열로 반환됩니다.
const users = [1,2,3,4,5]
const userQueries = useQueries(
users.map(user => {
return {
queryKey: ['user', user],
queryFn: () => fetchUserById(user),
}
})
)
useQuery에는 다양한 옵션을 사용할 수 있는데 이 중에서 몇가지 유용한 옵션에 대해 정리하면 다음과 같습니다.
useMutation은 서버를 대상으로 데이터를 수정 (create, update, delete) 하기 위해 사용하는 hook 입니다.
리턴값과 사용하는 방법은 useQuery와 비슷합니다.
다음 예제를 통해 useMutation의 사용 방법에 대해 알아보겠습니다. useMutation에 사용한 파라미터는 순서대로 다음과 같은데 파라미터의 구성도 useQuery와 동일합니다.
const mutation = useMutation(
'addUser',
addUserFuc,
{
onMutate: (variables) => {
console.log('onMutate', variables);
},
onError: (error, variables, context) => {
console.log('onError', context);
},
onSuccess: (data, variables, context) => {
console.log('onSuccess', data);
},
onSettled: (data, error, variables, context) => {
console.log('onSettled', data);
}
}
);
위에 사용된 옵션은 다음과 같습니다.
일반적으로 mutation이 성공적으로 동작한 이후에는 다른 관련된 쿼리의 refetch를 필요로 할 가능성이 높습니다. 이러한 경우엔 다음과 같이 QueryClient의 invalidQueries 함수를 사용해줍니다. 이렇게 하면 mutation 성공 이후에 해당 쿼리가 stale 상태로 변경 되어 캐시에서 삭제되고 refetch가 실행되게 됩니다.
const mutation = useMutation(postTodo, {
onSuccess: () => {
// postTodo가 성공하면 todos로 맵핑된 useQuery api 함수를 실행합니다.
queryClient.invalidateQueries("todos");
}
});
만약 mutation에서 return된 값을 이용해서 get 함수의 파라미터를 변경해야할 경우 setQueryData를 사용합니다.
const queryClient = useQueryClient();
const mutation = useMutation(editTodo, {
onSuccess: data => {
// data가 fetchTodoById로 들어간다
queryClient.setQueryData(["todo", { id: 5 }], data);
}
});
const { status, data, error } = useQuery(["todo", { id: 5 }], fetchTodoById);
mutation.mutate({
id: 5,
name: "nkh"
});
react-query를 사용하는 또 하나의 이유는 비동기를 좀 더 선언적 사용할 수 있어서 인 것 같습니다.
Suspense (opens new window)를 사용하며 loading을, Error buundary (opens new window)를 사용하여 에러 핸들링을 더욱 직관적으로 할 수 있습니다.
suspense를 사용하기 위해 QueryClient에 옵션을 하나 추가합니다. 아래 방법은 global하게 suspense를 사용한다고 정의할 때 예시입니다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
suspense: true
}
}
});
ReactDOM.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
document.getElementById("root")
);
아래는 suspense를 사용하는 예제입니다.
const { data } = useQurey("test", testApi, { suspense: true });
위처럼 세팅을 완료 했을 경우 react에서 제공하는 Suspense를 사용하면 됩니다.
const { data } = useQurey("test", testApi, { suspense: true });
return (
// isLoading이 true이면 Suspense의 fallback 내부 컴포넌트가 보여집니다.
// isError가 true이면 ErrorBoundary의 fallback 내부 컴포넌트가 보여집니다.
<Suspense fallback={<div>loading</div>}>
<ErrorBoundary fallback={<div>에러 발생</div>}>
<div>{data}</div>
</ErrorBoundary>
</Supense>
);
위와 같이 react query를 왜 사용하는지 장점은 무엇이 있는지, 사용법은 어떻게 되는지와 관련한 글이었습니다.