리액트 쿼리 공식문서 번역 및 요약
React Query
리액트 쿼리 공식문서를 정주행하면서 중요한 내용을 정리해보았습니다.
Overview
리액트 쿼리는 종종 누락된 데이터를 가져오는 라이브러리로 설명되곤 하지만, server state를 fetching, caching, synchronizing, updating할 수 있습니다. 기존의 상태관리 라이브러리들은 비동기적인 업무를 처리하거나 서버 상태를 관리하는데 좋지 않았습니다.
서버 상태는 우리가 통제할 수 없는 원격에서 유지되고 있으며, 비동기적인 API 호출을 요구하고, 다른 사용자에 의해 변경될 수 있고, 당신의 클라이언트에 있는 서버 상태는 oudated 상태가 될 수 있습니다.
서버상태를 관리하다보면, 캐싱, 요청의 중복 처리, 데이터의 최신 상태 관리, 최적화와 lazy loading 등에 문제를 겪게 됩니다. React Query는 복잡한 데이터 요청 로직을 단순화 시켜주고, 서버상태를 최신으로 유지시켜주고, 고객에게 빠르고 responsive한 경험을 느끼도록 도와줍니다.
Installation
$ npm i react-query
# or
$ yarn add react-query
위 코드를 터미널에 입력하여 설치합니다.
Queries
Query는 unique key
에 연결된 선언적 의존성입니다. 쿼리는 데이터를 가져오는 Promise-based method에 사용됩니다. 만약 서버의 상태를 변경하고 싶다면 Mutations 를 사용하여야 합니다. 쿼리를 구독하려면 useQuery
훅을 사용합니다. useQuery
는 unique key와 데이터를 Promise형태로 가져오는 콜백을 필요로 합니다.
import { useQuery } from '@tanstack/react-query'
function App() {
const info = useQuery(['todos'], fetchTodoList)
}
unique key는 내부적으로 refetching, caching, 쿼리 공유를 위해 쓰입니다.
const result = useQuery(['todos'], fetchTodoList)
쿼리를 통해 리턴되는 결과는 데이터를 다루는데 필요한 정보를 담고 있습니다. 아래와 같이 destructuring해서 사용할 수 있습니다.
const { status, isLoading, isError, isSuccess, data, error } = useQuery(['todos'], fetchTodoList)
아래와 같이 결과에 대해서 다른 컴포넌트를 리턴하는 방식으로 사용할 수 있습니다.
function Todos() {
const { status, data, error } = useQuery(['todos'], fetchTodoList)
if (status === 'loading') {
return <span>Loading...</span>
}
if (status === 'error') {
return <span>Error: {error.message}</span>
}
// also status === 'success', but "else" logic works, too
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
Query Keys
query key는 직렬화가 가능하다면 배열 안에 담아서 쓸 수 있습니다. 쿼리키는 직렬화가 가능하고, 고유해야합니다. 쿼리 키는 single string이 될 수도 있고, serializable의 배열이 될 수도 있습니다. 쿼리키는 deterministically 하게 해시됩니다. 아래 세 쿼리 키는 동일하게 간주됩니다.
useQuery(['todos', { status, page }], ...)
useQuery(['todos', { page, status }], ...)
useQuery(['todos', { page, status, other: undefined }], ...)
그러나, 순서가 달라지면 다른 키로 간주됩니다.
useQuery(['todos', status, page], ...)
useQuery(['todos', page, status], ...)
useQuery(['todos', undefined, page, status], ...)
쿼리 함수가 변수에 의존하고 있다면, 해당 변수는 쿼리 키 배열에 담겨있어야 합니다.
function Todos({ todoId }) {
const result = useQuery(['todos', todoId], () => fetchTodoById(todoId))
}
Query Functions
쿼리 함수는 프로미스를 리턴한다면 무엇이든 될 수 있습니다. 즉, data를 resolve하거나 에러를 던져야 합니다.
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]))
에러가 발생한다면 반드시 던져줘야 합니다. 에러는 result.error로 받을 수 있습니다.
const { error } = useQuery(['todos', todoId], async () => {
if (somethingGoesWrong) {
throw new Error('Oh no!')
}
return data
})
axios
나 graphql
등은 에러를 자동으로 던져주지만, 자바스크립트의 fetch API
는 에러를 던져주지 않기 때문에 반드시 핸들링을 해줘야 합니다.
useQuery(['todos', todoId], async () => {
const response = await fetch('/todos/' + todoId)
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
})
query key 배열은 고유하게 쿼리를 관리하는 역할도 하지만, 쿼리함수에게 주는 인자값이 되기도 합니다.
function Todos({ status, page }) {
const result = useQuery(['todos', { status, page }], fetchTodoList)
}
// Access the key, status and page variables in your query function!
function fetchTodoList({ queryKey }) {
const [_key, { status, page }] = queryKey
return new Promise()
}
query function은 QueryFunctionContext
를 인자로 받습니다. 이는 아래으 ㅣ구성으로 이루어져 있습니다.
queryKey: QueryKey
: Query Keys- pageParam: unknown | undefined
- only for Infinite Queries
- the page parameter used to fetch the current page
- signal?: AbortSignal
- AbortSignal instance provided by react-query
- Can be used for Query Cancellation
- meta?: Record<string, unknown>
- an optional field you can fill with additional information about your query
Network Mode
리액트 쿼리는 네트워크 연결이 없을 때 Queries
와 Mutations
를 구분할 수 있는 세 가지 모드를 가지고 있습니다. 이는 쿼리별/뮤테이션 별로 설정될 수 있습니다. 리액트 쿼리가 데이터 fetching에 많이 쓰이기 때문에 기본 모드는 online
입니다. 그러나 fetchStatus가 추가로 노출됩니다.
online
온라인 모드에서는 쿼리와 뮤테이션이 네트워크 연결이 없으면 작동하지 않습니다. 네트워크 연결이 중단된 경우 기존 상태를 유지합니다.
- fetching : 쿼리함수가 실제로 실행되고 있는 상태
- paused : 쿼리가 실행중이 아님
- idle : 쿼리가 fetching중이 아니고, 멈춘 적도 없음
isFetchingr 과 isPaused가 여기서 기인합니다.
loading 만으로 스피너를 보여주는 건 충분하지 않습니다. Queries가 loading이어도 online모드일 경우 인터넷 연결이 끊어진 상태면 fetchStatus : paused 상태일 수 있기 때문입니다.
always
이 모드에서는 항상 fetch를 합니다. active 한 network가 필요 없는 경우에 사용합니다.
offlineFirst
이 모드에서는 쿼리함수를 한 번 실행하고, retry를 일시정지합니다. offline-first PWA 나 캐시 컨트롤에 유용합니다. 첫번째 fetch는 오프라인 스토리지나 캐시로 부터 가져오기 때문에 성공할 확률이 높지만, 실패하는 경우 온라인 모드처럼 동작합니다. retry를 일시정지합니다.
Manual Parallel Queries
여러 개의 변하지 않는 쿼리를 병렬적으로 실행하고 싶으면 쭉 나열하면 됩니다. 그런데 suspense mode에서는 여러 개의 useQuery를 쓰면 첫번째에서 걸릴 수 있기 때문에, useQueries
를 써야합니다.
function App () {
// The following queries will execute in parallel
const usersQuery = useQuery(['users'], fetchUsers)
const teamsQuery = useQuery(['teams'], fetchTeams)
const projectsQuery = useQuery(['projects'], fetchProjects)
...
}
여러 개의 쿼리가 렌더링마다 변화되면서 실행된다면, 쿼리를 병렬적으로 나열하면 안되고, useQueries 훅을 써야 합니다.
function App({ users }) {
const userQueries = useQueries({
queries: users.map(user => {
return {
queryKey: ['user', user.id],
queryFn: () => fetchUserById(user.id),
}
})
})
}
Dependent Queries
Dependant queries는 이전 쿼리에 의존합니다. 의존하는 항목을 enabled
를 통해서 명시할 수 있습니다.
// Get the user
const { data: user } = useQuery(['user', email], getUserByEmail)
const userId = user?.id
// Then get the user's projects
const { status, fetchStatus, data: projects } = useQuery(
['projects', userId],
getProjectsByUser,
{
// The query will not execute until the userId exists
enabled: !!userId,
}
)
위 코드에서 userId를 boolean으로 주게 됩니다. 그러면 projects
쿼리의 상태는 아래와 같은 상태로 시작합니다.
status: 'loading'
fetchStatus: 'idle'
user가 준비되면, projects query가 enabled 상태가 되고, 아래 상태로 바뀝니다.
status: 'loading'
fetchStatus: 'fetching'
프로젝트 데이터를 가져오면, 아래 상태로 바뀝니다.
status: 'success'
fetchStatus: 'idle'
Background Fetching Indicators
status == loading
이라는 상태는 초기 hard-loading 상태를 보여주는 데는 충분하지만, refetching에 대한 상태를 나타내기에는 충분하지 않습니다. 그 문제는 isFetching
을 통해서 해결할 수 있습니다.
function Todos() {
const { status, data: todos, error, isFetching } = useQuery(
'todos',
fetchTodos
)
return status === 'loading' ? (
<span>Loading...</span>
) : status === 'error' ? (
<span>Error: {error.message}</span>
) : (
<>
{isFetching ? <div>Refreshing...</div> : null}
<div>
{todos.map(todo => (
<Todo todo={todo} />
))}
</div>
</>
)
}
전역적으로 fetching중인지를 검사하고 싶다면 useIsFetching을 사용하면 됩니다.
import { useIsFetching } from '@tanstack/react-query'
function GlobalLoadingIndicator() {
const isFetching = useIsFetching()
return isFetching ? (
<div>Queries are fetching in the background...</div>
) : null
}
Window Focus Refetching
유저가 앱을 나갔다가 오래된 데이터를 돌아오면, 리액트 쿼리는 자동적으로 최신의 데이터를 요청합니다. refetchOnWindowFocus
옵션을 통해서 이를 비활성화할 수 있습니다.
//Disabling Globally
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
//Disabling Per-Query
useQuery(['todos'], fetchTodos, { refetchOnWindowFocus: false })
특별한 상황에서, 리액트 쿼리가 재확인하도록 하는 당신은 포커스이벤트를 관리할 수 있습니다. focusManager.setEventListner
를 이용하면 됩니다. 이 API를 호출하면 이전의 핸들러는 제거되고, 새로운 핸들러로 덮어써집니다.
focusManager.setEventListener(handleFocus => {
// Listen to visibilitychange and focus
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('visibilitychange', handleFocus, false)
window.addEventListener('focus', handleFocus, false)
}
return () => {
// Be sure to unsubscribe if a new handler is set
window.removeEventListener('visibilitychange', handleFocus)
window.removeEventListener('focus', handleFocus)
}
})
Disabling/Pausing Queries
쿼리가 자동으로 실행되는 것을 막고 싶으면, enabled=false
옵션을 주면 됩니다. 이 경우,
- 쿼리가 데이터를 캐시했으면 쿼리는
isSuccesss
가 됩니다. - 캐시가 되어있지 않으면
status===loading
이나fetchStatus === idle
이 됩니다. - 마운트 됐을 때 쿼리가 데이터를 자동으로 가져오지 않습니다.
- background에서 자동으로 refetch되지 않습니다.
- useQuery로부터 반환되는 refetch는 query를 fetch하는데 사용할 수 있습니다.
function Todos() {
const {
isLoading,
isError,
data,
error,
refetch,
isFetching
} = useQuery(['todos'], fetchTodoList, {
enabled: false,
})
return (
<div>
<button onClick={() => refetch()}>Fetch Todos</button> // 여기서 수동으로 refetch
{data ? (
<>
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
) : (
isError ? (
<span>Error: {error.message}</span>
) : (
(isLoading && !isFetching) ? (
<span>Not ready ...</span>
) : (
<span>Loading...</span>
)
)
)}
<div>{isFetching ? 'Fetching...' : null}</div>
</div>
)
}
Lazy Queries
enabled옵션은 영구적으로 쿼리를 비활성화 할수도 있지만, 나중에 enable/disable할 수도 있습니다. enabled에 걸리는 값을 아래처럼 어떤 시점에 의존적으로 설정하면 딥니다.
function Todos() {
const [filter, setFilter] = React.useState('')
const { data } = useQuery(
['todos', filter],
() => fetchTodos(filter),
{
// ⬇️ disabled as long as the filter is empty
enabled: !!filter
}
)
return (
<div>
// 🚀 applying the filter will enable and execute the query
<FiltersForm onApply={setFilter} />
{data && <TodosTable data={data}} />
</div>
)
}
Edit on Github
Query Retries
쿼리가 실패하면 자동적으로 max number of consecutive retries 에 해당하는 값(기본 값 3)만큼 재시도를 합니다. 전역 또는 각 쿼리마다 해당 옵션을 설정할 수 있습니다.
import { useQuery } from '@tanstack/react-query'
// Make a specific query retry a certain number of times
const result = useQuery(['todos', 1], fetchTodoListPage, {
retry: 10, // Will retry failed requests 10 times before displaying an error
})
Retry Delay
실패를 했다고 바로 다시 요청을 보내지는 않습니다. 기본적으로 1초로 세팅되어있고, 바꿀 수 있습니다.
Paginated/Lagged Queries
페이지네이션은 리액트 쿼리로 대표적으로 구현할 수 있는 UI 패턴입니다.
const result = useQuery(['projects', page], fetchProjects)
위 예제에는 문제가 있습니다. 각각의 새 페이지가 완전히 새로운 쿼리처럼 처리되기 때문에 UI가 성공 및 로드 상태를 왔다갔다 합니다. 아래 해결책이 있습니다.
Better Paginated Queries with keepPreviousData
keepPreviousData에 true값을 주게 되면, 쿼리 키가 변경되었더라도, 새로운 데이터 요청 중에 마지막으로 성공적으로 fetching한 데이터에 접근을 할 수 있습니다. 새로운 데이터가 도착하면, 오래된 데이터가 갱신됩니다. isPreviousData
를 통해서 구별할 수 있습니다.
function Todos() {
const [page, setPage] = React.useState(0) //페이지 상태 정의
//페이지 값으로 해당 페이지의 프로젝트 데이터 가져오기
const fetchProjects = (page = 0) => fetch('/api/projects?page=' + page).then((res) => res.json())
const {
isLoading,
isError,
error,
data,
isFetching,
isPreviousData,
} = useQuery(['projects', page], () => fetchProjects(page), { keepPreviousData : true })
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : isError ? (
<div>Error: {error.message}</div>
) : (
<div>
{data.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</div>
)}
<span>Current Page: {page + 1}</span>
<button
onClick={() => setPage(old => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous Page
</button>{' '}
<button
onClick={() => {
if (!isPreviousData && data.hasMore) {
setPage(old => old + 1)
}
}}
// 콜백에 다음페이지가 준비됐는지를 조건으로 걸 수 있다.
disabled={isPreviousData || !data?.hasMore}
>
Next Page
</button>
{isFetching ? <span> Loading...</span> : null}{' '}
</div>
)
}
Infinite Queries
useInfiniteQuery
를 이용하면 무한스크롤을 구현할 수 있습니다. 아래와 같은 구성요소가 있습니다.
- data : infinite query data를 가지고 있는 객체입니다.
- data.pages : 페이지를 fetch한 array입니다.
- data.pageParams : pageParams를 가지고 있는 배열입니다.
fetchNextPage
와fetchPreviousPage
를 통해서 이전/다음 페이지를 가져올 수 있습니다.getNextPageParam
과getPreviousPageParam
을 통해서 더 가져와야할 정보가 있는지 결정할 수 있습니다.hasNextPage
라는 boolean 값을 이용할 수도 있습니다.isFetchingNextPage
와isFetchingPreviousPage
를 이용해서 background의 state를 확인할 수 있습니다.
아래와 같이 Load More UI를 구성할 수 있습니다.
- 기본적으로
useInfiniteQuery
가 첫 데이터그룹을 요청할 때까지 기다립니다. getnextPageParam
으로부터 다음 쿼리에대한 정보를 받습니다.fetchNextPage
함수를 호출합니다.
getNexPageParam
함수에서 반환된pageParam
을 덮어쓰지 않으려면fetchNextPage
arguments와 함께 호출하면 안됩니다!예를 들어,
<button onClick={fetchNextPage}/>
을 주게 되면 인자로 이벤트가 자동으로 들어가는데, 이 때 pageParam이 덮어써지게 됩니다.
function Projects() {
const fetchProjects = ({ pageParam = 0 }) =>
fetch('/api/projects?cursor=' + pageParam) //커서를 param으로 데이터를 가져오는 함수
//param을 args에 넣어서 활용하는 모습
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status,
} = useInfiniteQuery(['projects'], fetchProjects, {
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
//fetchProjects : cursor를 param으로 프로젝트 목록을 로드
//getNextPageParam을 통해 다음 요청에 필요한 param을 반환하는 함수를 작성합니다.
return status === 'loading' ? (
<p>Loading...</p>
) : status === 'error' ? (
<p>Error: {error.message}</p>
) : (
<>
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'} // 다음페이지가 있는지, 데이터를 가져오고있는지에 따라 다른 텍스트 표시
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
</>
)
}
infinite query가 refetch 되어야 할 때는?
infinite query는 stale
상태가 되고, 각 그룹이 순차적으로 가져와집니다. 기본 데이터가 변겨오디더라도 오래된 커서를 사용하지 않고 잠재적으로 사본을 얻거나 레코드를 건너 뜁니다. infinite query의 결과가 쿼리 캐시에서도 지워지면, pagination은 처음부터 다시 시작합니다.
refetchPage
subset page를 다시 가져오려면 refetch 함수에 refetchPage 정보가 담긴 함수를 넘깁니다.
query function에 custom information을 넣어주고 싶으면?
fetchNextPage를 이용해서 강제로 갱신할 수 있습니다. (getNextPageParam으로 일반적 방식이 아닌, 수동적 방식으로 param이 달라집니다.)
'Study Log' 카테고리의 다른 글
JS DeepDive 정리 1장 - 20장 (0) | 2022.08.11 |
---|---|
git branch 공부하기 (0) | 2022.02.26 |
Redux Toolkit 알아보기 (0) | 2022.02.10 |
Redux 공부하기 (0) | 2022.01.01 |
브라우저 공부 (0) | 2021.11.20 |
댓글
이 글 공유하기
다른 글
-
JS DeepDive 정리 1장 - 20장
JS DeepDive 정리 1장 - 20장
2022.08.11 -
git branch 공부하기
git branch 공부하기
2022.02.26 -
Redux Toolkit 알아보기
Redux Toolkit 알아보기
2022.02.10 -
Redux 공부하기
Redux 공부하기
2022.01.01