Background
상태는 두 개의 버킷으로 묶일 수 있습니다.
- UI 상태: 모달 open, 항목강조 등
- 서버 캐시: 사용자 데이터, 채팅 데이터, 연락처 등
- 내부 구조 알아보기. 언제 한번 번역해서 정리하려다가 못했음 ㅡㅡ;
기본적인 사용법은 다음과 같다.
function App({tweetId}) {
const result = useQuery({
queryKey: ['tweet', {tweetId}],
queryFn: (key, {tweetId}) =>
client(`tweet/${tweetId}`).then(data => data.tweet),
})
// result has several properties, here are a few relevant ones:
// status
// data
// error
// isLoading
const [removeTweet, state] = useMutation(() => tweetClient.remove(tweetId))
// call removeTweet when you want to execute the mutation callback
// state has several properties, here are a few relevant ones:
// status
// data
// error
}
📜 here are the docs:
- useQuery: https://react-query-v2.tanstack.com/docs/guides/queries
- useMutation: https://react-query-v2.tanstack.com/docs/guides/mutations
실습
프로젝트 구조
페이지 설명
- 책 상세보기 : Book.js
- 책 검색 : Discover.js
핵심 : 해당 URL에 대한 get, post, put, delete (query, mutation)을 하나의 훅으로 묶는다.
queryKey를 통해 데이터 변경 시 쿼리를 트리거한다.
mutation후 가장 간단한 구현 방법은 쿼리를 invalidate하는 것이다. (쿼리 키 이용)
해당 실습은 구현이 너무 많아서 전부 다 적을수 없는 수준이다.
해당 유틸리티를 보면 어느정도 이해가 갈 것이다.
React-Query 공통 설정 (ReactQueryConfigProvider)
useErrorBoundary : 리액트 쿼리의 에러를 throw한다.
refetchOnWindowFocus : 다른 앱을 작업하다 다시 브라우저를 작업하면 다시 쿼리한다. (거의 불필요한 기능)
retry : false면 재시도 하지 않는다.
import {loadDevTools} from './dev-tools/load'
import './bootstrap'
import * as React from 'react'
import ReactDOM from 'react-dom'
import {ReactQueryConfigProvider} from 'react-query'
import {App} from './app'
const queryConfig = {
queries: {
useErrorBoundary: true,
refetchOnWindowFocus: false,
retry(failureCount, error) {
if (error.status === 404) return false
else if (failureCount < 2) return true
else return false
},
},
}
loadDevTools(() => {
ReactDOM.render(
<ReactQueryConfigProvider config={queryConfig}>
<App />
</ReactQueryConfigProvider>,
document.getElementById('root'),
)
})
로그아웃 시 쿼리 캐시 clear
로그아웃 함수에서
// ...
import * as auth from 'auth-provider'
import {queryCache} from 'react-query'
// ...
// function App()
const logout = () => {
auth.logout()
queryCache.clear()
setData(null)
}
서버 응답 에러 시
// utils/api-client.final.js
import {queryCache} from 'react-query'
import * as auth from 'auth-provider'
const apiURL = process.env.REACT_APP_API_URL
async function client(
endpoint,
{data, token, headers: customHeaders, ...customConfig} = {},
) {
const config = {
method: data ? 'POST' : 'GET',
body: data ? JSON.stringify(data) : undefined,
headers: {
Authorization: token ? `Bearer ${token}` : undefined,
'Content-Type': data ? 'application/json' : undefined,
...customHeaders,
},
...customConfig,
}
return window.fetch(`${apiURL}/${endpoint}`, config).then(async response => {
if (response.status === 401) {
queryCache.clear()
await auth.logout()
// refresh the page for them
window.location.assign(window.location)
return Promise.reject({message: 'Please re-authenticate.'})
}
const data = await response.json()
if (response.ok) {
return data
} else {
return Promise.reject(data)
}
})
}
export {client}
인라인 에러 메세지
// ErrorMessage 표기 위한 컴포넌트
function ErrorMessage({error, variant = 'stacked', ...props}) {
return (
<div
role="alert"
css={[{color: colors.danger}, errorMessageVariants[variant]]}
{...props}
>
<span>There was an error: </span>
<pre
css={[
{whiteSpace: 'break-spaces', margin: '0', marginBottom: -5},
errorMessageVariants[variant],
]}
>
{error.message}
</pre>
</div>
)
}
// 함수형 컴포넌트 내부 label 옆에 에러를 inline으로 표기해 주는 것이 좋다.
function NotesTextarea({listItem, user}) {
// 뮤테이션에서 상태 가져올 수 있음
const [mutate, {error, isError}] = useUpdateListItem(user)
const debouncedMutate = React.useMemo(() => debounceFn(mutate, {wait: 300}), [
mutate,
])
function handleNotesChange(e) {
debouncedMutate({id: listItem.id, notes: e.target.value})
}
return (
<React.Fragment>
<div>
<label
htmlFor="notes"
css={{
display: 'inline-block',
marginRight: 10,
marginTop: '0',
marginBottom: '0.5rem',
fontWeight: 'bold',
}}
>
Notes
</label>
// label 옆에 에러를 inline으로 표기해 주는 것이 좋다.
{isError ? (
<ErrorMessage
error={error}
variant="inline"
css={{marginLeft: 6, fontSize: '0.7em'}}
/>
) : null}
</div>
<Textarea
id="notes"
defaultValue={listItem.notes}
onChange={handleNotesChange}
css={{width: '100%', minHeight: 300}}
/>
</React.Fragment>
)
}
커스텀 에러 핸들링
리액트 쿼리는 기본적으로 에러를 삼키기 때문에, 밖에서 에러를 이용하려면 설정이 필요하다.
// 커스텀 훅을 옵션을 오버리이딩 할 수 있도록 만든다.
function useUpdateListItem(user, options) {
return useMutation(
updates =>
client(`list-items/${updates.id}`, {
method: 'PUT',
data: updates,
token: user.token,
}),
{...defaultMutationOptions, ...options},
)
}
throwOnError설정을 추가해준다.
const [update] = useUpdateListItem(user, {throwOnError: true})
인라인 로딩 스피너 추가
라벨 옆에 로딩 스피너를 추가해주면 좋다.
//////// 함수형 컴포넌트 내부
// 훅 사용
const [mutate, {error, isError, isLoading}] = useUpdateListItem(user)
리턴 JSX 부분
(<div>
<label
htmlFor="notes"
css={{
display: 'inline-block',
marginRight: 10,
marginTop: '0',
marginBottom: '0.5rem',
fontWeight: 'bold',
}}
>
Notes
</label>
{isError ? (
<ErrorMessage
error={error}
variant="inline"
css={{marginLeft: 6, fontSize: '0.7em'}}
/>
) : null}
{isLoading ? <Spinner /> : null}
</div>)
Prefetch
미등록 리스트 > A 등록 > A 상세조회 > 미등록 리스트로 이동하면
캐시 때문에 A의 정보가 남아있다가 사라진다.
클린업 때 캐시를 invalidate하고,
해당 invalidate된 쿼리를 prefetch하면, 해당 쿼리를 비동기적으로 처리한다.
먼저 refetch와 쿼리를 위해 동시에 사용되는 config를 분리한다.
그 후 아래와 같이 훅을 구현한다.
const getBookSearchConfig = (query, user) => ({
queryKey: ['bookSearch', {query}],
queryFn: () =>
client(`books?query=${encodeURIComponent(query)}`, {
token: user.token,
}).then(data => data.books),
})
function useBookSearch(query, user) {
const result = useQuery(getBookSearchConfig(query, user))
return {...result, books: result.data ?? loadingBooks}
}
async function refetchBookSearchQuery(user) {
queryCache.removeQueries('bookSearch')
await queryCache.prefetchQuery(getBookSearchConfig('', user))
}
쿼리를 갱신해줘야 하는 페이지에 아래와 같은 부수효과를 추가한다.
(해당 쿼리를 갖고 있는 페이지에 추가한다!)
React.useEffect(() => {
return () => refetchBookSearchQuery(user)
}, [user])
캐시에 있는 정보 활용하기
react-query는 relay나 apollo-client같이 정규화된 쿼리를 사용하지 않는다.
따라서 이미 로딩된 정보가 있다면 해당 정보를 활용하는 설정이 필요하다.
const getBookSearchConfig = (query, user) => ({
queryKey: ['bookSearch', {query}],
queryFn: () =>
client(`books?query=${encodeURIComponent(query)}`, {
token: user.token,
}).then(data => data.books),
config: {
onSuccess(books) {
for (const book of books) {
setQueryDataForBook(book)
}
},
},
})
// 성공 시 queryKey로 cache data를 등록한다.
function setQueryDataForBook(book) {
queryCache.setQueryData(['book', {bookId: book.id}], book)
}
setQueryDataForBook를 export해서, 다른 곳에서도 해당 정보를 받아오면 세팅 가능하다.
function useListItems(user) {
const {data: listItems} = useQuery({
queryKey: 'list-items',
queryFn: () =>
client(`list-items`, {token: user.token}).then(data => data.listItems),
config: {
onSuccess(listItems) {
for (const listItem of listItems) {
setQueryDataForBook(listItem.book)
}
},
},
})
return listItems ?? []
}
개선방법 => 이벤트를 퍼블리싱 하는 방식으로 모듈 간의 결합도를 개선할 수 있다.
Optimistic Updates and Recovery
일단 클라이언트에서 정보를 수정하고, 서버에 요청한다.
그 후 서버와 동기화한다.
onMutate에서 캐시를 조작한다.
리턴 함수는 recover를 담당한다.
function useUpdateListItem(user, ...options) {
return useMutation(
updates =>
client(`list-items/${updates.id}`, {
method: 'PUT',
data: updates,
token: user.token,
}),
{
onMutate(newItem) {
const previousItems = queryCache.getQueryData('list-items')
queryCache.setQueryData('list-items', old => {
return old.map(item => {
return item.id === newItem.id ? {...item, ...newItem} : item
})
})
return () => queryCache.setQueryData('list-items', previousItems)
},
...defaultMutationOptions,
...options,
},
)
}
'FrontEnd' 카테고리의 다른 글
[Epic React][Build an Epic React App][Performance] (0) | 2022.01.01 |
---|---|
[Epic React][Build an Epic React App][Context] (0) | 2022.01.01 |
[Epic React][Build an Epic React App][Router][라우터] (0) | 2021.12.31 |
[Epic React][실전]Make HTTP Requests (0) | 2021.12.31 |
[Epic React][실전] 스타일 추가하기 with Emotion👩🎤 (0) | 2021.12.31 |