[0. React Query란]
TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.
리액트 어플리케이션에서 fetching, caching, synchronizing, updating server state를 지원하는 라이브러리이다.
이를 사용해 원래 fetch 함수를 선언하고, 이를 저장할 상태를 선언하고, useEffect를 이용해 컴포넌트를 마운트할 때 데이터를 fetch한 후 상태에 저장하는 과정을 거치는 작업을 단순화할 수 있다.
예시 코드는 다음과 같다.
import { useQuery } from "@tanstack/react-query";
const getServerData = async () => {
const data = await fetch(
"https://jsonplaceholder.typicode.com/posts"
).then((response) => response.json());
return data;
};
export default function App() {
const { data } = useQuery(["data"], getServerData);
return <div>{JSON.stringify(data)}</div>;
}
이를 통해 코드 수 증가로 인한 Side Effect를 제거하고 fetch 방식을 규격화하며 fetch를 동기적으로 실행할 수 있다.
동기적 실행의 예시는 다음과 같다.
const { data: data1 } = useQuery({ queryKey: ["data1"], queryFn: getServerData });
const { data: data2 } = useQuery({
queryKey: ["data2", data1], // data1을 포함시켜 쿼리 키를 동적으로 만듦.
queryFn: getServerData,
enabled: !!data1 // data1이 있을 때만 쿼리를 실행.
});
예시처럼 간단하게 data1이 fetch되었다는 것을 확인한 후 data2를 fetch하도록 구현할 수 있다.
또한 아래와 같은 경우에만 데이터를 fetch하고, 그 이외에는 자체적으로 캐싱한 데이터를 사용할 수 있게 하는 기능도 제공한다.
- 브라우저가 포커싱 된 경우
- 새로운 컴포넌트가 마운트된 경우
- 네트워크 재연결이 발생한 경우
- 일정한 시간이 지난 경우
이를 통해 불필요한 데이터 fetch를 막을 수 있다.
[1. Query Invalidation]
[1-1. 사용법]
tanstack query(react query)는 데이터의 상태를 다음과 같이 관리한다.
fetching -> fresh -> stale -> inactive -> delete
- fetching : 데이터 요청 상태.
- fresh : 데이터가 만료되지 않은 상태.(컴포넌트의 상태가 변경되어도 데이터를 다시 요청하지 않는다. 새로고침하면 다시 fetching.)
- stale : 데이터가 만료된 상태.( staleTime 기본값 0ms 가 지나면 fresh → stale 로 변경, 최신화가 필요한 데이터. 컴포넌트가 Mount, update 되면 데이터를 다시 요청)
- inactive : 사용하지 않는 상태.( cacheTime 기본값 5분이 지나면 Garbage Collector가 캐시에서 제거.)
- delete : Garbage Collector에 의해 캐시에서 제거된 상태.
이 중 stale 상태는 refetch를 대기하는 상태이다.
tanstack query는 다음과 같은 경우 stale로 명시된 데이터를 refetch한다.
- 새로운 query 인스턴스가 마운트될 때 (useQuery가 처음 호출될 때)
- 포커스가 브라우저 화면을 벗어났다가 다시 포커싱될 때
- 네트워크가 다시 연결될 때
- 설정한 refetch interval에 의해서 (refetchInterval)
- 고의적으로 invalidate하여 refetching. CUD(Create, Update, Delete)가 이루어진 직후 새로운 데이터를 받아오기 위해 invalidate를 함. 즉시 stale이 되어 refetching될 수 있음. (stale time 무시)
- staleTime이 infinity여서 stale상태로 가지 않도록 설정한 경우, invalidate할 경우에만 refetch된다
따라서 사용자의 로그인 등 즉시 데이터의 fetch가 필요한 경우 데이터를 고의적으로 invalidate해 refetch하는 경우가 있따.
queryClient.invalidateQueries({ queryKey: ['todos'] })
위처럼 'todos' 데이터를 invalidate로 만들면 todos는 즉시 stale 상태가 된다.
이는 staleTime의 옵션을 덮어쓴다.(즉시 stale 데이터로 만들기 때문)
이렇게 하면 'todos' 쿼리는 바로 refetch되어 새로운 값으로 화면을 다시 렌더링하게 된다.
[1-2. Query Matching]
쿼리 키를 이용해 무효화할 쿼리를 식별할 수 있다.
[’todos’]로 쓸 경우, [’todos’] 쿼리와, [’todos’,{page:1}] 같은 쿼리가 모두 무효화된다.
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Get QueryClient from the context
const queryClient = useQueryClient()
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Both queries below will be invalidated
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: fetchTodoList,
})
[’todos’,{page:1}]로 쓸 경우, [’todos’,{page:1}] 만 무효화되고, [’todos’]는 무효화되지 않는다.
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,
})
또는 조건문을 설정할 수도 있다.
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
// The query below will be invalidated
const todoListQuery = useQuery({
queryKey: ['todos', { version: 20 }],
queryFn: fetchTodoList,
})
// The query below will be invalidated
const todoListQuery = useQuery({
queryKey: ['todos', { version: 10 }],
queryFn: fetchTodoList,
})
// However, the following query below will NOT be invalidated
const todoListQuery = useQuery({
queryKey: ['todos', { version: 5 }],
queryFn: fetchTodoList,
})
왜 데이터를 직접 갱신하는 대신 현재 데이터를 무효화시키는가?
신뢰할 수 있는 직접 업데이트를 위해
→ 프론트엔드에 더 많은 코드 필요 + 백엔드에서의 중복 로직 필요
ex) 정렬된 목록은 직접적으로 업데이트하기 어려움 (내 항목의 위치가 변경될 수 있기 때문)
→ 전체 목록을 무효화하는 것이 더 안전한 접근 방식
[2. useMutation]
서버에 CUD 작업을 하기 위해, 또는 서버 사이드 이펙트를 활용하기 위해 사용한다.
mutation은 항상 다음과 같은 4가지 상태중 하나를 가진다.
- isIdle or status === 'idle' - mutation은 idle 상태이거나 불러오기/리셋 상태이다.
- isPending or status === 'pending' - mutation이 진행 중이다.
- isError or status === 'error' - mutation이 error 상태이다.
- isSuccess or status === 'success' - mutation이 성공적으로 완료되어 mutation data를 사용할 수 있다.
이 중 isError 혹은 isSuccess 상태에서는 각각 error과 data 프로퍼티에서 데이터를 가져와 사용할 수 있다.
function App() {
const mutation = useMutation({
mutationFn: (newTodo) => axios.post('/todos', newTodo)
});
return <button onClick={()=>mutation.mutate({id: new Date(), title:'Do Laundry'});
}
useMutation 훅은 인자로 mutationFn을 필수로, options를 선택적으로 받는다.
mutationFn은 변형 혹은 수정 작업을 수행하는 함수를 받는다.
mutationFn으로 fetch 함수를 할당하는 방식으로 사용할 수 있다.
options로 할당할 수 있는 값들은 아래와 같다.
const {
data,
error,
isError,
isIdle,
isPending,
isPaused,
isSuccess,
failureCount,
failureReason,
mutate,
mutateAsync,
reset,
status,
submittedAt,
variables,
} = useMutation({
mutationFn,
gcTime, // number | Infinity
// 사용되지 않거나 활성화되지 않은 캐쉬가 메모리에 남아있게 할 시간을 ms로 할당한다.
// 이 시간이 지나면 가비지컬렉터에서 자동으로 캐쉬를 수거한다.
// Infinity로 설정하면 가비지 컬렉션을 비활성화한다.(최대 24일)
meta, // Record<string, unknown>
// 값이 전달되면 mutation 캐쉬에서 사용할 추가 정보를 저장할 수 있다. mutation이 가능한 모든 곳에서 접근해서 가져올 수 있다.
mutationKey, // unknown[]
// mutation을 관리하고 캐싱된 정보를 확인하는 데 사용하는 키를 할당할 수 있다.
// 왜 배열을 받지?
networkMode, // 'online' | 'always' | 'offlineFirst'
// default = 'online'
// online에서는 네트워크가 없으면 mutation이 발동하지 않는다.
// always에서는 네트워크 상태를 무시하고 항상 fetch한다. 로컬스토리지에서 fetch해야 하는 것들을 사용할 때 유용하다.
// offlineFirst에서는 한 번 fetch를 발동하고 다음 시도를 일시정지한다. cache 데이터를 우선 사용할 때 유용하다.
onError, // (error: TError, variables, context?: TContext) => Promise<unknown> | unknown
// mutation이 error를 마주하면 발동한다. error를 받는다.
// promise가 반환된다면, promise가 resolve될 때까지 기다렸다가 발동한다.
onMutate, // (variables: TVariables) => Promise<TContext | void> | TContext | void
// mutation이 발동하기 전 발동시킬 함수를 지정한다. mutationFn이 전달받을 값을 전달한다.
// 낙관적 업데이트를 구현할 때 유용하다.
onSettled, // (data: TData, error: TError, variables, context?: TContext) => Promise<unknown> | unknown
// mutation이 error를 마주하든 성공하든 발동할 함수를 지정한다.
// promise가 반환된다면, promise가 resolve될 때까지 기다렸다가 발동한다.
onSuccess, // (data: TData, variables: TVariables, context: TContext) => Promise<unknown> | unknown
// mutation이 성공하면 발동시킬 함수를 지정한다. mutation의 결과를 전달받는다.
// promise가 반환된다면, promise가 resolve될 때까지 기다렸다가 발동한다.
retry, // boolean | number | (failureCount: number, error: TError) => boolean
// default = 0
// false면 mutation이 실패했을 때 재시도하지 않는다.
// true면 mutation이 실패했을 때 성공할 때까지 재시도한다.
// number값이 들어오면 그 값만큼 재시도한다.
retryDelay, // number | (retryAttempt: number, error: TError) => number
// 각 재시도 사이의 시간을 ms로 입력받는다.
// 숫자를 어떻게 전달하느냐에 따라 선형 그래프를 따르는 재시도 혹은 지수 그래프를 따르는 재시도를 수행할 수 있다.
scope, // {id: string}
// 기본적으로 모두 다른 id를 가지며, 다른 id를 가지는 mutation은 병렬적으로 실행된다.
// 같은 id를 가지는 mutation끼리는 순서에 따라 연속적으로 실행된다.
throwOnError, // undefined | boolean | (error: TError) => boolean
// true로 설정하면 에러 발생 시 렌더 페이즈에 가장 가까운 에러 바운더리로 에러를 throw한다. false는 throw하지 않음
// true를 반환하는 함수를 넣으면 에러를 전달한다. 함수가 true를 반환하면 에러 바운더리에서 에러를 보여주고, false를 반환하면 에러를 state로 반환한다.
})
mutate(variables, {
onError,
onSettled,
onSuccess,
}) // 컴포넌트별로 추가 트리거 콜백을 사용하고 싶은 경우, mutate를 지정할 수 있다.
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: (data, variables, context) => {
// I will fire first
},
onError: (error, variables, context) => {
// I will fire first
},
onSettled: (data, error, variables, context) => {
// I will fire first
},
})
mutate(todo, {
onSuccess: (data, variables, context) => {
// I will fire second!
},
onError: (error, variables, context) => {
// I will fire second!
},
onSettled: (data, error, variables, context) => {
// I will fire second!
},
})
또한 mutation.reset()을 사용해 쿼리의 error와 data를 삭제할 수 있다.
const CreateTodo = () => {
const [title, setTitle] = useState('')
const mutation = useMutation({ mutationFn: createTodo })
const onCreateTodo = (e) => {
e.preventDefault()
mutation.mutate({ title })
}
return (
<form onSubmit={onCreateTodo}>
{mutation.error && ( // 에러 발생해서 초기화 버튼 클릭시 초기화.
<h5 onClick={() => mutation.reset()}>{mutation.error}</h5>
)}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<br />
<button type="submit">Create Todo</button>
</form>
)
}
번외: useMutationState
v5버전부터, mutation 상태를 컴포넌트 간 공유하는 useMutationState hook을 사용할 수 있음
// useMutationState
import { useMutationState } from '@tanstack/react-query'
const variables = useMutationState({
filters: { status: 'pending' },
select: (mutation) => mutation.state.variables,
})
[3. 낙관적 업데이트]
낙관적 업데이트는 서버에서 응답을 받을 때까지 기다리지 않고 사용자에게 빠른 피드백을 제공함으로서 사용자 경험을 증진시키는 업데이트 기법이다.
예를 들어 서버와의 통신 시간이 느리거나 많은 데이터를 전송하고 가져와야 하는 등의 이유로 사용자에게 빠른 피드백을 전달하지 못하는 경우가 있다.(장바구니 미션에서 느린 통신 환경에서의 quantity 수정 등)
이를 해소하기 위해 원래는 서버 상태에 무관한 로컬 상태를 따로 지정한 후, 서버와 통신을 실패하거나 도중에 다른 이벤트가 발생했을 때의 경우를 모두 분기처리해 수행할 작업을 구현해 주어야 했다.
하지만 react query에서는 두 가지 방식의 낙관적 업데이트 구현이 가능하다.
[3-1. UI를 통한 낙관적 업데이트]
variables를 받아, isError나 isPending일 때에 variables를 보여준다.
// somewhere in your app
const { isPending, submittedAt, variables, mutate, isError } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
// make sure to _return_ the Promise from the query invalidation
// so that the mutation stays in `pending` state until the refetch is finished
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// while pending
<ul>
{todoQuery.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
// if error
{
isError && (
<li style={{ color: 'red' }}>
{variables}
<button onClick={() => mutate(variables)}>Retry</button>
</li>
)
}
const variables = useMutationState({})를 이용해서 variables만을 가져올 수도 있다.
// somewhere in your app
const { mutate } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
})
// access variables somewhere else
const variables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
UI를 이용해 낙관적 업데이트를 구현하는 경우, rollback을 구현할 필요 없다.(자동으로 rollback이 실행됨)
단, 한 번에 화면의 여러 곳에서 업데이트 상황에 대한 정보가 필요한 경우, 그리고 그 데이터가 서로 다른 경우 캐시를 통한 낙관적 업데이트를 사용하는 것이 좋다.
(한 곳에서는 CartList의 아이템 개수를, 다른 곳에서는 총 금액을 사용하는 경우 등)
[3-2. 캐시를 이용한 낙관적 업데이트]
useMutation({
mutationFn: updateTodo,
// mutate가 호출되면 onMutate를 실행
onMutate: async (newTodo) => {
// 진행중인 refetch가 있다면 취소
// 취소하지 않는다면 refetchOnMount등을 true로 해뒀을 때
// 페이지를 들어오자 마자 refetch를 하면 refetch가 두번 실행되며
// 화면에 최신 데이터를 그려주지 않을 가능성이 있다.
// 이를 방지하기 위해 cancelQueries를 실행시켜준다.
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 이전 쿼리값의 스냅샷
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// setQueryData 함수를 사용해 newTodo로 Optimistic Update를 실시한다.
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// context를 리턴하는데 여기에는 이전 스냅샷, 새로운 값을 넣어 리턴하게 한다.
// 혹은 롤백 함수를 여기서 리턴해줘도 된다.
return { previousTodo, newTodo }
},
// If the mutation fails, use the context we returned above
// mutation 실패시 onMutate가 리턴한 context를 사용해 값을 되돌린다.
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', context.newTodo.id],
context.previousTodo,
)
},
// 성공하거나 실패시 쿼리를 무효화해 최신 데이터를 받아와준다.
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] })
},
})
[4. Data Prefetching]
사전에 데이터를 가져오고 캐시에 저장한다.
저장된 캐시 데이터는 바로 stale 상태, 즉 만료된 상태로 지정된다.
그 후 사용자가 실제로 데이터에 접근 요청을 하면 일단 캐시 데이터를 보여주는 동시에 데이터를 다시 fetch한다.
사용자의 동작이 예상되는 경우, 앞으로 사용자가 불러올 가능성이 높은 데이터를 Prefetch하면 사용자 경험을 향상시킬 수 있다.
사용 방법은 다음과 같다.
const prefetchNextPosts = async (nextPage: number) => {
const queryClient = useQueryClient();
// 다른 쿼리같이 캐싱됨.
await queryClient.prefetchQuery({
queryKey: ["posts", nextPage],
queryFn: () => fetchPosts(nextPage),
// ...options
});
};
// 미리 다음 페이지의 데이터를 받아와서 캐시 후 렌더링
useEffect(() => {
const nextPage = currentPage + 1;
if (nextPage < maxPage) {
prefetchNextPosts(nextPage);
}
}, [currentPage]);
프리페칭 함수를 onClick에 바로 설정할 경우, setState는 비동기로 실행되기 때문에 프리페칭이 진행될 때에는 현재 페이지가 어디인지 알 수 없어 제대로 동작하지 않는다.
ex) n페이지에서 n + 1 페이지로 넘어갈 때, n + 2 페이지를 프리페치하는 동작을 onClick에 넣으면 프리페치가 실행될 때 시점이 페이지가 n인 때인지, n + 1인 때인지 알 수 없다.
따라서 useEffect의 의존성 배열에 조건을 걸어 프리페칭이 실행되도록 하는 것이 적합하다.
[5. v5의 핵심 변경사항]
1. useQuery, useInfiniteQuery, useMutation 는 이제 객체 형식의 인자로 전달해야 함.
v4에서는
(1) useQuery(key, fn, options)
(2) useQuery({ queryKey, queryFn, ...options })
두 형태를 모두 지원했는데 이는 유지보수가 힘들고, 매개 변수 타입을 확인하기 위한 런타임 검사도 필요했기 때문에 이제는 객체만 허용 (good)
2. useQuery의 옵션인 onSuccess, onError, onSettled가 없어짐
버그 유발할 수 있어 제거됨.
useMutation 의 경우 해당 옵션 계속 사용 가능
3. cacheTime이 gcTime으로 변경
데이터가 캐시되는 시간이 아니라, 가비지 콜렉터에 의해 캐시가 없어지는 시간 이라는 의미를 강조하기 위해 gcTime으로 이름이 바뀜
4. queryClient.getQueryData ,queryClient.getQueryState 의 인수가 queryKey만 받게 수정
- queryClient.getQueryData(queryKey, filters)
+ queryClient.getQueryData(queryKey)
- queryClient.getQueryState(queryKey, filters)
+ queryClient.getQueryState(queryKey)
5. useErrorBoundary ⇒ throwOnError
ErrorBoundary에 에러를 던지기 위해 사용했던 옵션인 useErrorBoundary를 특정 프레임워크에 종속되지 않으면서, 리액트 커스텀 훅의 접미사인 use와 ErrorBoundary 컴포넌트명과 혼동을 피하기 위해, throwOnError로 변경됨.
const todos = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
- useErrorBoundary: true,
+ throwOnError: true,
})
6. error의 기본 타입이 Error 로 변경
7. loading 옵션이 pending으로 변경되었으며, 마찬가지로 isLoading 플래그가 isPending으로 변경