[React] Tanstack Query(react-query) 훑어보기
웹 어플리케이션에서 데이터 패칭, 캐싱, 서버 상태와 동기화 및 업데이트(fetching, caching, synchronizing and updating server state)를 쉽게 만들어주는 library
이 포스트에서는 react-query의 기초적인 개념 몇 가지에 대해 다룬다.
Installation
yarn add @tanstack/react-query
eslint와 함께 사용하는 것을 추천, 세팅은 아래 링크 참조
https://tanstack.com/query/latest/docs/eslint/eslint-plugin-query
Quick Start: queryClient
https://tanstack.com/query/latest/docs/framework/react/quick-start
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
// Create a client
const queryClient = new QueryClient()
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<div/>
</QueryClientProvider>
)
}
queryClient를 선언하고, QueryClientProvider를 통해 queryClient를 하위 컴포넌트에서 사용할 수 있도록 세팅한다.
queryClient는 react-query의 대부분의 기능을 담고 있는 핵심 객체기 때문에, react-query를 사용하기 위해 최상위 컴포넌트에서 Provider로 하위 컴포넌트들에게 제공되어야만 한다.
이제부터 react-query의 다음 3가지 기본 개념을 소개할 것이다.
- queries
- mutations
- query invalidation
Quick Start: Queries
https://tanstack.com/query/latest/docs/framework/react/guides/queries
쿼리는 고유 키에 연결된 비동기 데이터 소스에 대한 선언적 종속성입니다.
A query is a declarative dependency on an asynchronous source of data that is tied to a unique key.
어렵게 보이지만, 쿼리는 어떤 ‘키’에 대한 ‘값’을 가져오는 것을 말한다.
간단히 예를 들어, ‘지금 날짜’라는 키를 요청하여 ‘20240211’라는 값을 가져오는 것을 ‘쿼리’라고 할 수 있다. (지금 날짜를 쿼리하여 ‘20240211’ 데이터를 얻는다)
그런데 react-query에서 주로 다루고자 하는 것은 서버가 반환하는 데이터와 같이 외부로부터 받아온 데이터다.
예를 들어 ‘유저 A의 회원정보’를 알기 위해서는 서버로부터 데이터를 받아와야만(외부로부터 데이터 필요) 알 수 있다.
위처럼 외부로부터 받아오는 데이터를 다루기 위해서는 어떤 것들이 필요할지 생각해 보자
- 쿼리 키 (어떤 유저의 어떤 정보인지 구분하는 값; 데이터 id)
- 쿼리 방법 (api fetch 함수; 어떻게 데이터를 가져올 것인지)
- 쿼리 값 (api fetch 결과 또는 이전에 캐싱된 값; 쿼리 키와 쿼리 방법으로부터 얻어낸 결과물)
- ...더 많은 속성들이 있을 수 있지만 나중에 생각하자
react-query 에서는 위에서 설명한 쿼리 과정과 요소를 아래와 같이 표현할 수 있다.
const { data } = useQuery({
queryKey: '/info/a',
queryFn: ()=>{
//api fetch and return response data...
return api.user.info.load('a');
},
});
queryKey에 대한 data가 존재하는지 확인하고, 없으면(정확히는 데이터 갱신이 필요하면) queryFn을 호출하여 그 결과값으로 data를 갱신한다.
쿼리에 대한 더 많은 정보도 얻을 수 있을까?
const { isPending, isError, data, error } = useQuery({
queryKey: '/info/a',
queryFn: ()=>{
//api fetch and return response data...
return api.user.info.load('a');
},
});
쿼리를 갱신하는데 실패하거나(queryFn 실행 중 error가 발생한 경우 등), 갱신 진행 중이거나, 어떤 에러가 발생했는지와 같은 다양한 정보들도 알아낼 수 있다.
이 값에 따라 UI를 표시할 때 일반적인 패턴은
const { isPending, isError, data, error } = useQuery({
queryKey: '/info/a',
queryFn: ()=>{
//api fetch and return response data...
return api.user.info.load('a');
},
});
if(isPending){
return <div>로딩 중</div>;
}
if(isError){
console.error(error);
return <div>오류 발생</div>;
}
return <div>정보: {data}</div>
- 로딩 체크
- 에러 체크
- 데이터 표시
의 순서대로 체크하여 쿼리 결과에 따라 UI를 분기할 수 있다.
(물론 목적에 따라 순서가 바뀌거나 빠질 수 있다)
query key
https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
쿼리 데이터는 쿼리 키(queryKey)로 분류되어 관리된다.
const { isPending, isError, data, error } = useQuery({
queryKey: '/info/a',
queryFn: ()=>{
//api fetch and return response data...
return api.user.info.load('a');
},
});
예시에서는 query key를 단순 string으로 설정했지만, 실제로는 더 복잡한 형태의 쿼리 키를 설정할 수 있다.
useQuery({ queryKey: ['todos'], ... })
useQuery({ queryKey: ['something', 'special'], ... })
useQuery({ queryKey: ['todo', {done: true}], ... })
쿼리 키는 기본적으로 배열이어야 하고, 배열의 원소들은 직렬화(Serializable) 가능한 모든 데이터 타입을 사용할 수 있으며, 내부적으로 해쉬되어 사용된다.
같은 키는 같은 결과를 반환하고, 다른 키는 다른 결과를 반환한다.
주의할 점은 쿼리 키는 결정적(deterministic)으로 해쉬되어 사용된다는 것이다.
자바스크립트에서
const obj1= {a: 1};
const obj2= {a: 1};
obj1 === obj2 //false
는 자연스럽지만, react-query에서 query key에서는 다음과 같다.
const hash= (queryKey)=>{
//react-query에서 queryKey를 해시하는 함수
//...
}
const queryKey1= hash(['todos', {done: true}]);
const queryKey2= hash(['todos', {done: true}]);
queryKey1 === queryKey2 //true; 같은 키로 취급
const queryKey3= hash(['todos', {done: true, important: true}]);
const queryKey4= hash(['todos', {important: true, done: true}]);
queryKey3 === queryKey4 //true; 같은 키로 취급 (object가 담고 있는 내용은 동일하다)
const queryKey5= hash(['todos', '1', '2']);
const queryKey6= hash(['todos', '2', '1']);
queryKey5 === queryKey6 //false
//object에는 순서 개념이 없어 키 순서가 달라도 같은 데이터가 될 수 있지만,
//array는 원소의 순서가 다르면 서로 다른 데이터이다
그런데 이전 예시들에선 배열이 아닌 string만을 사용했는데 어떻게 된 것일까?
//이것은
useQuery({queryKey: 'todos', ...})
//이것과 동일하다
useQuery({queryKey: ['todos'], ...})
useQuery가 자동으로 string 배열로 바꾸어주기 떄문에 키로 string을 사용해도 문제가 없었던 것이다.
Quick Start: mutation
https://tanstack.com/query/latest/docs/framework/react/guides/mutations
query와는 달리, mutation은 일반적으로 데이터의 생성/변경/삭제나 서버의 사이드이펙트(부수효과)를 일으키는데 사용됩니다.
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects.
mutation은 데이터의 생성/변경/삭제 (변형)을 일으키거나, 서버의 사이드이펙트를 일으키는 데 사용된다.
SWR을 학습한 경험이 있다면 혼동이 올 수 있다.
SWR에서는 client-side에서 데이터를 변경하는 것을 mutation라고 하지만, react-query에서는 서버의 상태를 변경하는 것 을 mutation이라고 한다는 것을 염두에 두자.
function App() {
const mutation = useMutation({
mutationFn: (newTodo) => {
return axios.post('/todos', newTodo)
},
})
return (
<div>
{mutation.isPending ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}
위의 예제 코드는 mutation의 기본 개념을 소개한다.
- 버튼을 누르면 mutation.mutate 함수가 호출된다.
- mutate가 처리 중(isPending)이면 ‘Adding todo…’를 표시한다.
- mutate가 처리 실패(isError)상태라면 에러 메시지를 표시한다.
- mutate가 처리 성공(isSuccess)되었다면 ‘Todo added!’ 를 표시한다.
mutation이라는 조금 거창한 이름을 가지고 있지만, request를 추상화한것에 불과하다.
mutation이라는 낯선 단어 대신 http request로 생각해 보자.
- 버튼을 누르면 http request 함수가 호출된다.
- http request 가 처리 중(isPending)이면 ‘Adding todo…’를 표시한다.
- http request 가 실패(isError)상태라면 에러 메시지를 표시한다.
- http request 가 성공(isSuccess)되었다면 ‘Todo added!’ 를 표시한다.
별로 특별할 것 없는 기능이 되었다.
우리가 request를 구현할 때, [로딩, 성공, 실패] 등의 케이스를 고려할 때 코드가 복잡해지는 경험을 한번쯤은 해본 적이 있을 것이다.
mutation은 그것을 하나의 규칙으로 묶어낸 것 뿐이다.
다시 mutation 얘기로 돌아와서, mutation의 라이프사이클은 다음 순서로 진행된다.
- idle (아직 실행되지 않음)
- pending (실행 중)
- success 또는 error (실행 결과에 따라 성공실패)
이는 코드 상에서도 확인할 수 있다.
const mutation = useMutation({
mutationFn: (newTodo) => {
return axios.post('/todos', newTodo)
},
})
const {isIdle, isPending, isError, isSuccess} = mutation;
3단계로 와서 실행이 끝나면, mutation은 그 상태로 유지된다.
물론 다시 mutate를 호출하면 실행은 가능하지만 idle 또는 pending 상태로 다시 돌아가는것이 아니라 다음 결과에 따라 success 또는 error상태로만 변경된다.
- 첫 번째 mutate가 성공했다면 상태는 다음과 같이 변화한다.
- idle→ pending→ success
- 그런 다음 mutate가 실패한다면 상태는 다음과 같이 변화한다.
- success → error
만약 다시 idle상태로 만들어서 다음 번 mutate가 idle부터 시작하도록 변경하려면, mutation.reset() 를 호출하여 mutation 상태를 초기화할 수 있다.
mutation callback
mutation의 각 페이즈마다 콜백을 지정하거나, 추가적으로 side effect를 발생시킬 수 있다.
useMutation({
mutationFn: addTodo,
onMutate: (variables) => {
// mutation이 발생한 직후
// 선택적으로 롤백이 발생할 때 사용할 데이터를 포함한 conext를 포함할 수 있다
return { id: 1 }
},
onError: (error, variables, context) => {
// 에러 발생
console.log(`rolling back optimistic update with id ${context.id}`)
},
onSuccess: (data, variables, context) => {
// 성공
},
onSettled: (data, error, variables, context) => {
// 에러 발생 또는 성공 둘중 하나가 발생
},
})
콜백 실행 순서는 onMutate → onErrror / onSuccess → onSettled 이다.
만약 콜백에서 promise를 반환한다면, 이전 라이프사이클 콜백이 처리되기 전까지 다음 라이프사이클 콜백은 시작되지 않는다
useMutation({
mutationFn: addTodo,
onSuccess: async () => {
console.log('onSuccess');
// sleep 5s
await sleep(5000);
},
onSettled: async () => {
console.log('onSettled');
},
})
useMutation에서 지정한 콜백 외 추가적인 콜백이 필요한 경우에는 다음과 같이 mutation을 호출할 때 지정할 수있다.
useMutation({
mutationFn: addTodo,
onSuccess: (data, variables, context) => {
console.log('1');
},
onError: (error, variables, context) => {
console.log('1');
},
onSettled: (data, error, variables, context) => {
console.log('3');
},
})
mutate(todo, {
onSuccess: (data, variables, context) => {
console.log('2');
},
onError: (error, variables, context) => {
console.log('2');
},
onSettled: (data, error, variables, context) => {
console.log('3');
},
})
호출 순서는 mutate에서 지정한 콜백보다 useMutation에서 지정한 콜백이 우선하며, 이 경우에도 콜백에서 promise를 반환하면 처리가 끝날떄까지 대기한다.
한 번의 mutate에 대해 출력 순서는 1→ 2→ 3으로 보장된다.
여러 번의 mutate가 발생한다면, useMutate에 지정한 콜백과 mutate에 지정한 콜백은 조금 다르게 동작한다.
useMutation({
mutationFn: addTodo,
onSuccess: (data, error, variables, context) => {
// 3번 호출됨
},
})
const todos = ['Todo 1', 'Todo 2', 'Todo 3']
todos.forEach((todo) => {
mutate(todo, {
onSuccess: (data, error, variables, context) => {
// 어떤 것이 먼저 완료되든지, 마지막 mutate(Todo3)에 대해 한번 실행된다
},
})
})
mutation observer가 mutate가 실행될 때 마다 제거되었다가 다시 추가되기 때문에, mutate가 완료되지 않은 상태에서 다시 mutate가 실행된다면 콜백을 받지 못할 수도 있다.
반대로 useMutation의 경우 그렇지 않기 때문에 3번 모두 호출된다.
promise
mutate() 의 반환값은 void 타입이다. (오류가 발생해도 error가 throw 되지 않고, await를 사용하더라도 mutation에 지정한 mutationFn 완료를 대기하지 않음)
만약 mutate를 promise 처럼 다루고 싶다면 mutateAsync() 를 사용한다.
const mutation = useMutation({ mutationFn: addTodo })
try {
const todo = await mutation.mutateAsync(todo)
console.log(todo)
} catch (error) {
console.error(error)
} finally {
console.log('done')
}
retry (재시도)
에러가 발생해도 기본적으로 재시도하진 않는다. 만약 에러가 발생했을 때 일정 횟수만큼 재시도하도록 하고 싶다면 retry 옵션을 사용한다.
// error가 발생하면 3번 재시도
const mutation = useMutation({
mutationFn: addTodo,
retry: 3,
})
Quick Start: Query Invalidation
어떤 데이터가 지정한 시간이 되기 전에도 stale 상태(오래된 상태; 데이터 갱신이 필요한 상태)가 될 수 있다.
예를 들어 todo list에서 가져온 리스트를 60초에 한 번 갱신한다는 정책이 있을 때, 사용자가 데이터를 삭제하거나, 강제 리프레시를 일으켰을 때 60초가 지나지 않았더라도 즉시 갱신이 필요하다.
// 캐시에 있는 모든 쿼리 무효화
queryClient.invalidateQueries()
// todos로 시작하는 키를 가진 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] })
invalidateQueries를 이용해, 지정한 staleTime과 관계 없이 쿼리를 stale상태로 만들고, 자동으로 갱신하도록(queryFn 재호출) 할 수 있다.
invlidateQueries나 removeQueries같은 API를 사용할 때는 여러 개의 쿼리나, 정확한 단 하나의 쿼리를 대상으로 할 수 있다.
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Get QueryClient from the context
const queryClient = useQueryClient()
// todos 쿼리 키를 가진 쿼리를 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] })
// 두 쿼리 모두 무효화됨
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: fetchTodoList,
})
쿼리는 ‘계층적’으로 동작한다는 사실을 명심하자. 무효화할 쿼리 키를 [‘todos’]로 지정하면, ‘todos’를 포함해 하위에 있는 쿼리들까지 전부 무효화된다.
예를 들어 다음과 같은 쿼리가 있다고 생각해보자
- queryKey: [‘a’]
- queryKey: [’a’,, ’b’]
- queryKey: [’a’, ’b’, ’c’]
여기서 invalidateQueries({queryKey: ['a']}) 하면 세 쿼리가 모두 invalidate된다.
만약 invalidateQueries({queryKey: ['a', 'b']}) 하면 [’a’, ‘b’]와 [’a’,’b’,’c’] 쿼리가 invaldate된다.
즉 지정한 쿼리와, 하위에 있는 쿼리가 대상이 되는 것이다.
이것을 이해하고 아래 코드를 보면 아주 쉽게 이해할 수 있다.
queryClient.invalidateQueries({
queryKey: ['todos', { type: 'done' }],
})
// 이 쿼리는 무효화된다
const todoListQuery = useQuery({
queryKey: ['todos', { type: 'done' }],
queryFn: fetchTodoList,
})
// 하지만 이 쿼리는 무효화되지 않는다
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
하지만 만약 하위 쿼리는 그대로 두고, 지정한 쿼리만 대상으로 하고 싶다면 어떨까? 이 경우에는 exact 옵션을 사용할 수 있다.
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true,
})
// The query below will be invalidated
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
// However, the following query below will NOT be invalidated
const todoListQuery = useQuery({
queryKey: ['todos', { type: 'done' }],
queryFn: fetchTodoList,
})
exact 옵션을 사용하면 지정한 쿼리 키와 정확히 일치하는 쿼리 하나만 무효화한다.
- queryKey: [‘a’]
- queryKey: [’a’,, ’b’]
- queryKey: [’a’, ’b’, ’c’]
여기서 invalidateQueries({queryKey: ['a'], exact: true}) 하면 [’a’] 쿼리만 모두 invalidate된다. (exact 옵션 없이는 세 쿼리가 모두 invalidate되었다)
만약 invalidateQueries({queryKey: ['a', 'b'], exact: true}) 하면 [’a’, ‘b’]쿼리가 invaldate된다. (exact 옵션 없이는 [’a’, ‘b’] 와 [’a’, ‘b’, ‘c’] 쿼리가 invalidate 되었다)
또는 predicate 옵션을 사용해, 쿼리 키를 직접 전달하지 않고 함수를 통해 대상을 지정할 수도 있다
queryClient.invalidateQueries({
//지정한 predicate가 true를 반환하는 쿼리만 invalidate
//첫 번째 쿼리 키가 todos이고, 두 번째 쿼리 키의 버전이 10 이상인 쿼리만 invalidate
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
// 쿼리 키가 predicate를 만족하므로 이 쿼리는 invalidate 된다
const todoListQuery = useQuery({
queryKey: ['todos', { version: 20 }],
queryFn: fetchTodoList,
})
// 쿼리 키가 predicate를 만족하므로 이 쿼리는 invalidate 된다
const todoListQuery = useQuery({
queryKey: ['todos', { version: 10 }],
queryFn: fetchTodoList,
})
// 쿼리 키가 predicate를 만족하지 않으므로 이 쿼리는 invalidate 되지 않는다
const todoListQuery = useQuery({
queryKey: ['todos', { version: 5 }],
queryFn: fetchTodoList,
})