개요: Remote(Server-side) State management
- 적은 코드로 원격 데이터를 패치하고 관리할 수 있게 해주는 라이브러리이다.
- 리액트 쿼리는 서버 데이터를 캐싱하여 여러 컴포넌트에서 동일한 쿼리에 대해 데이터를 공유한다.
- 이는 마치 리덕스에 상태를 저장해두고 어디에서나 사용할 수 있도록 하는 것과 유사하다.
- 서버 데이터는 서버에서 관리하는 데이터이며, 여러 클라이언트에게 동시에 제공한다. 반면 클라이언트 데이터는 현재 서비스를 이용하는 각각의 사용자 위한 데이터이다.
- 이러한 네트워크 바운더리에 따라 서로 다른 특징을 갖는 데이터를 분리하여 관리할 수 있다. 이를 가능하게 하는 것이 바로 캐시다.
- 캐시를 활용하여 서버 데이터를 클라이언트 측 state management에서 소유하지 않고 따로 관리할 수 있다.
- 리액트 쿼리는 서버 측 데이터를 클라이언트에서 가능한 최신 상태로 유지하며 사용자에게 가능한 빠르게 보여줄 수 있도록 도와준다.
Always code for re-renders, and a lot of them. I like to call it render resiliency.
항상 리렌더링이 자주 일어나도록 코드를 짜라. 이를 렌더 탄력성이라고 부를 수 있다.
— Tanner Linsley (리액트 쿼리 개발진)
***컴포넌트가 재평가 될 때마다 쿼리도 계속 재호출되는 것은 아니다. 쿼리 refetch를 트리거하는 특정 상황들이 존재한다. 이를 통해 네트워크 리소스를 최적화한다.
***반대로 staleTime:0이 아니어도 쿼리가 refetch될 수 있음 -> 컴포넌트가 처음 마운트될 때 새 쿼리 인스턴스가 생성되고 항상 네트워크 요청이 발생한다. (단, 리렌더링은 데이터가 변경된 것이 존재 할 때만 일어남)
리액트 쿼리는
- [useState, useEffect, fetch API] 구문을 간소화하고 재사용성을 높인다. 이를 통해 프론트엔드를 백엔드에 연결(sync) 하기가 더욱 수월해진다.
*state-> data, isLoading, isError 등등...
- http 요청을 관리하는 로직을 제공한다. (mutation, re-fetch, pre-fetch ... 등 / fetch 로직은 직접 작성해야함)
- 캐시 기능 지원 (캐시 기간 설정, 캐시 무효화 등)
import {useEffect, useState} from 'react';
function App () {
const [data, setData] = useState();
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(()=>{
async function fetchEvents() {
setIsLoading(true);
const response = await fetch('http://localhost:3000/events');
if (!response.ok) {
const error = new Error('An error occurred while fetching the events');
error.code = response.status;
error.info = await response.json();
throw error;
}
const { events } = await response.json();
return events;
}
fetchEvents()
.then((events) => {
setData(events);
})
.catch((error) => {
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<div>
{isLoading && <LoadingIndicator />}
{isError && <ErrorBlock />}
{data && <Events events={data} />}
</div>
);
}
위를 아래와 같이 간단하게 관리 할 수 있다.
// App.js
import {useQuery} from 'react-query';
function App () {
const { data, isPending, isError, error } = useQuery({
queryKey: ["events"],
queryFn: fetchEvents,
});
return (
<div>
{isLoading && <LoadingIndicator />}
{isError && <ErrorBlock />}
{data && <Events events={data} />}
</div>
);
}
// index.js
import App from './App'
import {QueryClientProvider, QueryClient} from 'react-query';
const queryClient = new QueryClient();
function Index() {
return (
<div>
<QueryClientProvider client={queryClient} />
<App />
</<QueryClientProvider>
</div>
);
}
코드 살펴보기
1. QueryClient
- 여러 설정과 함께 데이터가 존재할 장소와 리액트 쿼리 로직을 담은 인스턴스를 생성한다.
- 프로바이더 컴포넌트로 이 리액트 쿼리 기능을 사용할 컴포넌트 트리를 래핑하여 query client props를 제공한다.
- queryClient 인스턴스를 통해 쿼리 로직을 직접 programmatic하게 사용할 수 있다.
- queryClient.invalidateQueries(): 전달한 쿼리키를 공통으로 갖는 모든 쿼리의 캐시 데이터를 무효화한다.
*mutate를 사용한 후 인위적으로 최신의 백엔드 데이터를 클라이언트가 반영하도록 보장한다. (쿼리 refetch는 특정한 상황에서 발동하므로 mutate된 내용을 즉각 반영하지 않을 수 있기 때문에 사용한다.)
* invalidated된 쿼리의 캐시는 stale 상태가 되고, 만약 쿼리가 마운트 후 비활성 상태라면(현재 컴포넌트가 useQuery에 의해 렌더링된 상태라면) 백그라운드에서 refetch 된다.
*exact:true 속성은 해당 쿼리키만 갖는 쿼리를 무효화함.
*refetchType:'none' 속성은 해당 쿼리 키를 갖는 비활성화(unmount) 상태의 다른 쿼리 인스턴스가 자동으로 트리거 되지 않게 함.
2. useQuery
queryKey
A query is a declarative dependency on an asynchronous source of data that is tied to a unique key.
모든 쿼리는 쿼리 키에 종속되며, 같은 키 값을 갖는 쿼리는 캐시 데이터를 공유한다.
쿼리 키 값은 [ string, number, {...} ] 등의 value를 갖는다.
- 쿼리 라이프 사이클:
1) 처음으로 새 'a' 쿼리 인스턴스가 마운트되면, 쿼리는 네트워크 요청을 보내 데이터를 가져와 'a' 이름으로 캐싱하고 캐시 기간을 설정한다.
2) 다른 컨텍스트에서 'a' 쿼리의 인스턴스가 마운트되면, 캐시에서 'a' 키에 대응하는 데이터를 즉시 가져온다.
3) 만약 'a' 쿼리의 캐시 데이터가 stale 상태라면 백그라운드에서 새 네트워크 요청을 트리거한다.
4) refetch 결과로 stale한 'a' 캐시 데이터는 fresh 한 새 데이터로 업데이트된다. 이때 1에서의 쿼리 인스턴스도 함께 업데이트 되며, 두 'a' 쿼리 인스턴스는 모두 fresh remote data를 반환하고 컴포넌트는 리렌더링된다.
5) 이후 'a' 쿼리의 gcTime이 만료될 때까지 'a' 쿼리 인스턴스가 활성화되지 않으면 메모리에서 제거 된다.
(*쿼리 refetch는 백그라운드에서 자동으로 진행되고, 그 동안 쿼리는 사용 가능한 캐시 데이터를 즉시 반환한다. 쿼리 요청이 성공적으로 완료되면 캐시에 새 데이터가 채워진다 -> stale 상태의 데이터를 먼저 보여주고, 업데이트가 완료되면 최신의 데이터로 리렌더링함. 사용자는 로딩 화면을 기다리지 않음.)
(https://tanstack.com/query/latest/docs/react/guides/caching)
- stale한 쿼리가 re-fetch되는 상황은 다음과 같다. 그리고 업데이트한 새 캐시 데이터가 기존과 다르다면 리액트는 컴포넌트를 리렌더링한다.
1) 새 쿼리 인스턴스가 마운트될 때 (refetchOnMount) (위 쿼리 라이플사이클에서 2번에 해당. inactive한 쿼리가 다시 마운트될 때)
2) 윈도우 창이 re-focus될 때 (refecthOnWindowFocus)
3) 네트워크가 re-connect될 때 (refetchOnReconnect)
4) 쿼리가 re-fetch interval 설정을 가질 때 (refetchInterval)
(https://tanstack.com/query/latest/docs/react/guides/important-defaults)
cache
- 캐싱된 페이지를 방문하면 네트워크 요청 없이 캐시 데이터를 즉시 렌더링 한다.
*즉시 렌더링한다는 뜻은 사용자에게 로딩 화면을 띄우지 않는다는 것이다.
*이미지는 캐시 되지 않는다.
- 캐시 설정: 메모리에 캐시된 데이터의 업데이트 시기, 데이터를 보관하는 기간을 설정한다.
1) stateTime: 기본 값은 0이다. 이 시간이 지나면 캐시된 데이터가 fresh 상태에서 stale 상태로 전환된다.
2) gcTime: 비활성화된 쿼리를 가비지 컬렉터가 수집한다. 기본 값은 5분이며, 이 시간이 지나면 캐시 데이터를 폐기한다.
- 일반적으로 gcTime은 기본값으로 두고, stateTime을 조정한다.
queryFn: fetch api 로직을 처리하고 프로미스를 반환하는 함수이다.
- {queryKey, signal}등 쿼리 정보가 담긴 option 객체를 인자로 전달하여 호출한다.
enabled: boolean 반환. 쿼리의 실행 시점을 제어한다. 컴포넌트의 리렌더링 사이클과 연결해야함(useState와 함께 사용)
3. useMutation
- 기본 쿼리로도 post 요청을 처리할 수 있지만 이 훅이 데이터 변경 처리에 최적화 되어 있다.
- 기본 쿼리와 달리 mutaionFn을 호출하는 mutate 함수를 반환하여 요청을 보내는 시점을 지정할 수 있다.
- mutation은 백엔드 데이터를 수정하는 것이 주목적이므로 응답 데이터를 굳이 캐싱할 필요가 없다. 이에 따라 쿼리키도 쓰지 않는다. (백엔드로부터 데이터를 가져와서 클라이언트에 저장하고 사용하는 작업은 기본 쿼리로 처리)
- 대신, mutate 후 추가 작업을 진행할 수 있는 옵션을 제공한다. (onSettled(), onSuccess(), onError() 등)
-> mutate는 비동기 작업(mutaionFn)을 처리하는 래퍼 함수임. 이벤트 핸들러에서 mutate 후속 작업을 수행하기 위해 try/catch 등 문법을 사용하기 어려움. onSettled 등의 옵션을 이용해서 네트워크 요청의 성공/실패 시나리오에 따른 렌더링 작업을 수행해야 한다.
4. Optimistic Updates
- 백엔드의 응답을 기다리지 않고 클라이언트에서 ui를 미리 업데이트 함. 만약 요청이 실패하면 업데이트 이전 내용으로 롤백함.
- onMutate, onError, onSettled 등의 옵션과 queryClient를 사용하여 구현한다.
- onMutate는 컴포넌트 내에서 mutate 함수가 호출되면 동시에 호출되는 메소드이다. mutate 비동기 프로세스가 완료되기 전에 이 메소드로 클라이언트 내 캐시 데이터를 직접 업데이트 할 수 있다. (원래라면 백엔드 응답을 받으면 리액트 쿼리가 자동으로 캐시를 업데이트함)
const { mutate } = useMutation({
mutationFn: updateEvent,
onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ["event-detail", id] });
// navigate("../");
// -> 기존에는 mutate가 완료되면 해당 쿼리를 무효화한 후 후속 작업을 처리했으나,
// 낙관적 업데이트는 무효화가 없고 후속 작업은 이벤트 핸들러(handleSubmit)에서 수행한다.
},
onMutate: async data => {
const previousEvent = queryClient.getQueryData(["event-detail", id]);
await queryClient.cancelQueries({ queryKey: ["event-detail", id] }),
// 쿼리키에 해당하는 모든 쿼리의 활성을 취소함.
// 응답데이터와 낙관적으로 업데이트된 쿼리 캐시 데이터가 충돌하지 않도록 함.
queryClient.setQueryData(["event-detail", id], data.event);
// 쿼리키의 캐시 데이터를 직접 업데이트함.
return { previousEvent };
// onMutate가 반환하는 객체는 mutate가 이행되었을때 실행되는 onError 등 메소드의
// context 매개변수의 인자로 전달된다.
},
onError: (error, data, context) => {
queryClient.setQueryData(["event-detail", id], context.previousEvent);
// mutate가 실패하는 경우, 낙관적 업데이트를 롤백한다.
},
onSettled: () => {
queryClient.invalidateQueries(["event-detail", id]);
// 혹시 롤백된 쿼리 데이터가 백엔드의 최신데이터와 다를 수 있으니 이를 다시 한 번 확인한다.
},
});
function handleSubmit(formData) {
mutate({ id, event: formData });
navigate("../");
}
읽어보기
https://www.timegambit.com/blog/digging/react-query/01
'React' 카테고리의 다른 글
[React] useContext (0) | 2023.04.29 |
---|---|
[React] Recoil (0) | 2023.03.21 |
[React] Redux Toolkit & Middleware (0) | 2022.10.31 |
[REACT] Nested Routes (0) | 2022.10.07 |
[React] Custom Hooks (0) | 2022.10.01 |