React Query가 없던 시절 리덕스를 사용할때는 서버에서 데이터를 가져온뒤 리덕스에 보관해서 전역상태로 보관했다.
1. Server State란
그래서 서버 상태를 다른 클라이언트 상태와 똑같이 취급했다.
하지만 서버 상태(사용자 정보, 글 목록 등)의 경우 클라이언트가 소유하지 않는 정보이다.
유저에게 가장 최신 버전을 화면에 표시하기 위해 서버에서 빌려온 것일 뿐이다. (데이터를 소유하는 것은 서버임)
2. 기본값 설명
먼저 React Query는 기본 staleTime이 0인 경우에도 매번 리렌더링할때 queryFn을 호출하지 않는다.
- staleTime
- query(데이터)를 fresh하게 놔둘 시간(fresh -> stale 시간), 기본값 0
- staleTime 내에 다시 query 요청 시 fetch하지 않고 캐시에서 읽어온다
- staleTime이 지난 경우 백그라운드에서 리페치가 발생한다.
- gcTime
- 받아온 query가 inactive 상태 일때부터 캐시에서 제거될 때까지의 시간, 기본값 5분
- query를 사용하는 모든 컴포넌트가 unmount될때 해당 query는 inactive 상태가 된다.
보통 staleTime을 조정하고, gcTime을 변경할 경우는 거의 없다.
3. queryKey를 dependency array처럼 취급하기
React Query는 queryKey가 변경될 때마다 refetch한다.
이 동작은 useEffect의 dependency array와 비슷하다.
type State = 'all' | 'open' | 'done'
type Todo = {
 id: number
 state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
 const response = await axios.get(`todos/${state}`)
 return response.data
}

export const useTodosQuery = (state: State) =>
 useQuery({
	queryKey: ['todos', state],
	queryFn: () => fetchTodos(state),
 })
여기서 필터 옵션과 함께 할 일 목록이 있다고 가정해보자.
사용자가 필터를 변경하자마자 로컬 상태가 업데이트되고, 쿼리키가 변경되므로 React Query가 자동으로 리페치할 것이다.
즉 query함수는 queryKey에 “반응” 하고 있는 것이다.
또한 fetch할때 쓰이는 상태는 항상 queryKey에 넣어주는게 일반적인 것 같다.
3.1 queryKey 활용예시: 사전 필터링으로 초기 데이터 보여주어 UX 개선하기
할 일 목록을 가져오는 엔드포인트 todos/all
(모든 할 일), todos/open
(남은 할 일), todos/done
(완료한 할 일)이 있다고 해보자.
사용자가 할 일 필터링 옵션을 all
에서 done
으로 바꾸는 경우 페치될 때 까지 로딩 스피너가 보여질 수 있다.
이를 개선할 방법이 있을까?
type State = 'all' | 'open' | 'done'
type Todo = {
 id: number
 state: State
}
type Todos = ReadonlyArray<Todo>

// 할 일 목록 가져오기
const fetchTodos = async (state: State): Promise<Todos> => {
 const response = await axios.get(`todos/${state}`)
 return response.data
}

// useQuery
export const useTodosQuery = (state: State) =>
 useQuery({
	queryKey: ['todos', state],
	queryFn: () => fetchTodos(state),
	initialData: () => { 
	 const allTodos = queryClient.getQueryData<Todos>([
		'todos',
		'all', // 캐시에 있는 모든 할 일 가져오기
	 ])
	 const filteredData =
		allTodos?.filter((todo) => todo.state === state) ?? []

	 return filteredData.length > 0 ? filteredData : undefined
	},
 })
useQuery
의 initialData
옵션을 이용해서 all
의 캐시 데이터를 done
상태로 필터링한 값을 초기 데이터로 보여줄 수 있다.
이렇게 하면 done
할 일을 즉시 보여줄 수 있고, 백그라운드 페치 이후에는 실제 done
서버 상태를 볼 수 있을 것이다.
4. 서버와 클라이언트 상태 분리하기
useQuery에서 가져온 데이터는 로컬 state에 넣지 말자.
다시 말해 prop으로 받은 query를 state로 재정의 하는 것도 좋지 않다.
다만 첫번째 렌더링시에만 서버 상태값을 보여줘야하고(초기화), 그 서버 상태가 바뀔일이 없는 경우엔 사용해도 괜찮다.
서버 상태가 바뀔일 없기 때문에 백그라운드 페칭은 리소스 낭비이다.
따라서 한번만 가져오기 위해 staleTime: Infinity
를 선언해주자.
const App = () => {
 const { data } = useQuery({
	queryKey: ['key'],
	queryFn,
	staleTime: Infinity,
 })

 return data ? <MyForm initialData={data} /> : null
}

// prop인 initialData(서버상태)가 바뀌어도 data(로컬상태)가 최신화되지 않는 문제가 발생한다.
// 즉, 서버상태와 로컬상태가 불일치한다.
// 다만 initialData가 바뀔일이 없기 때문에 이렇게 작성해도 괜찮음
const MyForm = ({ initialData }) => {
 const [data, setData] = React.useState(initialData)
 ...
}
서버 상태가 계속 바뀌는 경우에는 최신화가 중요하다.
따라서 서버상태를 로컬상태로 재정의하면 동기화가 어렵다.
그런데 그 상태를 사용자가 조작해야하는 경우엔 머리가 아플 수 있다.
서버상태는 보통 사용자에게 최신의 데이터를 보여주는 용도로 자주쓰인다.
반면에 보통 값을 조작하고 싶을때는 로컬상태가 필요하다.
이때 이 두 가지 요구사항이 동시에 요구될때 실수할 수 있는게 받아온 서버상태를 로컬상태로 초기화하는 행위이다.
대신 로컬상태와 서버상태를 따로 선언하고, 서버 상태를 최신화할 조건(enabled
)을 정의해주자.
그러면 서버상태와 로컬상태의 불일치를 최소화할 수 있다.
const useRandomValue = () => {
 const [draft, setDraft] = React.useState(undefined);
 const { data, ...queryInfo } = useQuery(
	"random",
	async () => {
	 await sleep(1000);
	 return Promise.resolve(String(Math.random())); // 서버상태가 계속 바뀐다.
	},
	{
	 enabled: typeof draft === "undefined", // 유저의 로컬상태(draft) 조작여부
	}
 );

 return {
		// 따로 선언한 로컬상태(draft)와 서버상태(data), 
		// 로컬상태가 정의되지 않으면(유저의 조작이 없다면) 최신의 서버상태를 계속 보여준다.(백그라운드 페칭)
	value: draft ?? data,
	setDraft,
	queryInfo,
 };
};
음.. 아마 v5에서 useQuery
의 onSuccess
옵션이 사라진 이유중에 하나이지싶다.
받아온 서버상태(data
)를 조작해야하는 경우 로컬상태(localState
)가 필요한데,
사람들이 그때 onSuccess
를 이용해 data ⇒ setLocalState(data)
를 자주해서 그렇지 않을까?
그 대신 useRandomValue
예시처럼 서버상태와 로컬상태를 나눠서 작성하면 react query 의도에 맞게 사용할 수 있다.
5. useQuery의 enabled 옵션은 강력하다.
enabled
는 query를 페치할지말지를 결정하는 옵션이다.
예를 들어 refetchInterval
로 데이터를 정기적으로 폴링하는 쿼리가 하나 있지만 모달이 열려 있으면 화면 뒤쪽의 업데이트를 피하기 위해 일시적으로 쿼리 페칭을 막을 수 있다.
또한 페칭을 하기위해 다른 쿼리의 데이터가 필요한 경우 종속 쿼리라고 하는데, 이때 enabled
옵션으로 간단하 게 구현할 수 있다.
// dependent Query (종속 쿼리)
// Get the user
const { data: user } = useQuery({
 queryKey: ['user', email],
 queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
 status,
 fetchStatus,
 data: projects,
} = useQuery({
 queryKey: ['projects', userId],
 queryFn: getProjectsByUser,
 // The query will not execute until the userId exists
 enabled: !!userId,
})
이런 종속 쿼리는 waterfall을 발생시켜서 성능을 저하시킨다.
그래서 가능하면 두 쿼리를 병렬로 가져올 수 있도록 백엔드 API를 재구성하는 것이 좋지만,
현실적으로 힘든 경우도 있다.
6. custom hook을 만들자
useQuery
를 사용할때는 hook으로 감싸서 사용하자.
특정 queryKey
의 모든 설정을 하나의 파일에서 보관하고 조정할 수 있다.
Reference
tkdodo blog 글을 보면서 제 나름대로 정리한 글입니다.
'React Query' 카테고리의 다른 글
6. React Query가 필요한 이유 (0) | 2024.07.21 |
---|---|
5. React Query의 Status (0) | 2024.02.21 |
4. 효율적으로 React Query Key 선언하기 (5) | 2024.02.14 |
3. React Query와 에러핸들링 (2) | 2024.02.08 |
2. Form에서 React Query 잘 활용하기 (0) | 2024.01.22 |