Photo by Christian Lue
TkDodo의 Why You Want Need React Query을 번역한 글입니다.
React Query는 React 앱에서 우리가 비동기 상태와 상호작용하는 방식을 간소화했고, 그래서 제가 React Query를 ❤️한다는 것은 비밀이 아닙니다. 그리고 많은 동료 개발자도 같은 생각이라는 것을 압니다.
하지만 서버로부터 데이터를 가져오는 것만큼 "간단한" 작업에는 React Query가 필요 없다고 주장하는 글을 마주칠 때가 종종 있습니다.
React Query가 제공하는 추가 기능이 하나도 필요없어서, 단순히
useEffect
에서fetch
할 수 있는 상황에 서드파티 라이브러리를 추가하고 싶진 않아요.
어느 정도는 타당한 지적이라고 생각합니다. React Query는 캐싱, 재시도, 폴링, 데이터 동기화, 프리페칭 등 이 글의 범위를 훨씬 뛰어넘는 수많은 기능을 제공합니다. 여러분에게 이 기능이 필요없다면 완전 괜찮지만, 그래도 그게 React Query를 사용하지 않는 이유가 되어서는 안 된다고 생각합니다.
프레임워크
데이터의 fetch나 mutation용 솔루션이 내장된 프레임워크를 사용한다면, React Query가 필요 없을 수도 있습니다.
대신 최근 트위터에 올라온 일반적인 fetch-in-useEffect
의 예시를 살펴보고, 왜 이런 상황에서도 React Query를 사용하는 것이 좋은지 알아보겠습니다.
// fetch-in-useEffect
function Bookmarks({ category }) {
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => setData(d))
.catch(e => setError(e))
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
위 코드가 추가 기능이 필요없는 간단한 유스케이스에 적합하다고 생각하셨다면 제가 이 10줄의 코드를 보자마자 숨어 있는 🐛버그 5개🪲를 찾았다는 사실을 말씀드려야겠네요.
잠시 시간을 내서 전부 찾을 수 있는지 확인해 보세요. 기다릴게요...
힌트: 의존성 배열은 아닙니다. 그건 문제 없어요.
React 공식 문서에서 데이터 fetch에 프레임워크나 React Query 같은 라이브러리를 권장하는 이유가 있습니다. 실제 fetch 요청을 하는 것은 꽤 사소한 작업일 수 있지만, 앱에서 해당 상태를 예상대로 사용할 수 있게 만드는 것은 간단하지 않습니다.
effect는 category
가 변경될 때마다 다시 fetch하도록 설정되어 있고, 그게 맞습니다. 하지만 네트워크 응답은 요청 순서와 다르게 도착할 수 있습니다. category
를 books
에서 movies
로 변경했는데 movies
의 응답이 books
의 응답보다 먼저 도착하면 컴포넌트에 잘못된 데이터가 존재하게 됩니다.
결국 category
의 상태는 movies
인데 실제 렌더링하는 데이터는 books
인, 일관되지 않은 상태와 함께하게 되죠.
React 문서에 따르면 클린업 함수와 ignore
라는 불리언으로 해결할 수 있다니 그렇게 해보겠습니다.
// ignore-flag
function Bookmarks({ category }) {
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
> let ignore = false
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
> if (!ignore) {
> setData(d)
> }
.catch(e => {
> if (!ignore) {
> setError(e)
> }
})
> return () => {
> ignore = true
> }
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
이제 category
가 변경되면 effect의 클린업 함수가 실행되고 ignore
플래그는 true가 됩니다. 이후에 도착하는 fetch 응답에 대해선 더 이상 setState
를 호출하지 않을 겁니다. 쉽네요.
로딩 상태가 하나도 없습니다. 요청이 진행되는 동안 보류 중인 UI를 표시할 방법이 없죠. 첫 번째 요청도, 그 이후에도 없습니다. 그러니 추가할까요?
// loading-state
function Bookmarks({ category }) {
> const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState([])
const [error, setError] = useState()
useEffect(() => {
let ignore = false
> setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
}
.catch(e => {
if (!ignore) {
setError(e)
}
})
> .finally(() => {
> if (!ignore) {
> setIsLoading(false)
> }
> })
return () => {
ignore = true
}
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
data
를 빈 배열로 초기화하면 undefined
인지 매번 확인하지 않아도 되니 좋은 생각 같습니다. 하지만 아직 등록된 항목이 없는 카테고리를 fetch 했는데 실제로 빈 배열이 반환되면 어떻게 될까요? 데이터가 "아직 없는 것"과 "전혀 없는 것"을 구분할 방법이 없을 것입니다. 아까 도입한 로딩 상태가 도움되지만, 여전히undefined
로 초기화하는 것이 더 낫습니다.
// empty-state
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
> const [data, setData] = useState()
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
}
.catch(e => {
if (!ignore) {
setError(e)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
data
와 error
는 둘 다 별도의 상태 변수이고, category
가 변경되어도 재설정되지 않습니다. 즉, 카테고리 하나를 fetch 하다가 실패하고, 다른 카테고리로 바꿔서 성공하면 상태는 아래와 같을 겁니다.
data: dataFromCurrentCategory // 현재 카테고리의 데이터
error: errorFromPreviousCategory // 이전 카테고리의 에러
이러면 결과는 상태에 따른 JSX 렌더링을 어떻게 하냐에 따라 달라집니다. error
를 먼저 확인하면 유효한 데이터가 있어도 에러 UI와 함께 이전의 메시지를 렌더링할 겁니다.
// error-first
return (
<div>
{error ? (
<div>Error: {error.message}</div>
) : (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</div>
))}
</ul>
)}
</div>
)
data
를 먼저 확인하면 두 번째 요청이 실패할 때 같은 문제가 발생합니다. 에러와 데이터를 항상 함께 렌더링하면 이전의 정보도 렌더링하게 될 것입니다. 😔
이 문제를 해결하려면 카테고리가 변경될 때 지역 상태(data
, error
)를 재설정해야 합니다.
// reset-state
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState()
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
.then(res => res.json())
.then(d => {
if (!ignore) {
setData(d)
> setError(undefined)
}
.catch(e => {
if (!ignore) {
setError(e)
> setData(undefined)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
StrictMode
에서 두 번 실행됨 🔥🔥이건 버그라기보다는 성가신 일이지만, 확실히 React가 새로운 개발자들을 방심하게 만드는 문제입니다. 앱을 <React.StrictMode>
로 감싸면, React는 개발 모드에서 의도적으로 이펙트를 두 번 호출해서 누락된 클린업 함수와 같은 버그를 찾을 수 있도록 도와줍니다.
두 번 실행되는 걸 방지하려면 "ref를 사용한 대안"을 또 추가해야 하는데, 그럴만한 일은 아니라고 생각합니다.
React Query에서도 같은 문제가 발생할 수 있어서 원래의 버그 목록에 넣지는 않았습니다. fetch
는 HTTP 에러가 발생해도 reject를 하지 않아서 res.ok
을 확인하고 직접 에러를 던져야 합니다.
// error-handling
function Bookmarks({ category }) {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState()
const [error, setError] = useState()
useEffect(() => {
let ignore = false
setIsLoading(true)
fetch(`${endpoint}/${category}`)
> .then(res => {
> if (!res.ok) {
> throw new Error('Failed to fetch')
> }
> return res.json()
})
.then(d => {
if (!ignore) {
setData(d)
setError(undefined)
}
.catch(e => {
if (!ignore) {
setError(e)
setData(undefined)
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [category])
// 데이터와 에러 상태에 따른 JSX 반환
}
fetch가 에러 응답을 reject하지 않는 이유
fetch가 이렇게 동작하는 이유에 대해 자세히 알아보려면 Artem Zakharchenko가 작성한 이 훌륭한 글을 확인해 보세요.
"그냥 데이터를 fetch 하고픈 것뿐인데, 얼마나 어렵겠어?"라는 생각으로 사용한 useEffect
훅은 예외 케이스와 상태 관리를 고려해야 하게 되자 엄청 더러운 스파게티 코드🍝가 되어버렸습니다. 여기서 배울 점은 무엇일까요?
여기가 React Query가 개입하는 지점입니다. React Query는 데이터 fetch 라이브러리가 아니라(!) 비동기 상태 관리자이니까요. 그러니 엔드포인트로부터 데이터를 fetch 하는 것 같은 간단한 작업에 React Query를 사용하고 싶지 않다고 하신다면 사실 여러분이 맞습니다. React Query를 사용한다 해도 똑같은 fetch
코드를 작성해야 하거든요.
하지만 앱에서 비동기 상태를 예상대로 사용할 수 있게 만드는 걸 최대한 쉽게 하려면 여전히 React Query가 필요합니다. 왜냐면, 우리 솔직해져 봐요, 저는 React Query를 사용하기 전에 ignore
라는 불리언 값을 작성해 본 적이 없고, 아마 여러분도 마찬가지일 테니까요. 😉
React Query를 사용하면 위에서 봤던 코드는 이렇게 됩니다.
// react-query
function Bookmarks({ category }) {
const { isLoading, data, error } = useQuery({
queryKey: ['bookmarks', category],
queryFn: () =>
fetch(`${endpoint}/${category}`).then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch')
}
return res.json()
}),
})
// 데이터와 에러 상태에 따른 JSX 반환
}
코드양은 위의 스파게티 코드의 약 50%이고, 버그투성이 원본 코드와 거의 같은 양입니다. 그리고 이렇게 하면 저희가 발견한 모든 버그가 자동으로 해결됩니다.
placeholderData
같은 기능으로 더욱 향상시킬 수 있습니다.StrictMode
에 의해 실행된 것도 포함해서, 중복된 fetch가 효율적으로 제거됩니다.아직도 React Query를 원하지 않는다고 생각하신다면 다음 프로젝트에서 사용해 보시길 감히 권해드리고 싶습니다. 예외 케이스에 더 탄력적으로 대응할 수 있을 뿐만 아니라 유지보수와 확장이 더 쉬운 코드를 작성할 수 있을 겁니다. 그리고 React Query가 제공하는 모든 기능을 한 번 맛보고 나면 아마 다시는 돌아보지 않을 거예요.
Query.gg 🔮
저는 ui.dev와 함께 React Query의 새로운 공식 강의를 준비해 왔습니다. 이 강의에서는 React Query가 내부적으로 어떻게 작동하는지 그리고 확장할 수 있는 React Query 코드를 작성하는 방법에 대한 기본 원리를 이해하게 될 겁니다. 제가 지금까지 만든 콘텐츠가 마음에 드셨다면 query.gg도 마음에 드실 겁니다.
트위터에서 많은 사람들이 원본 코드에 요청 취소 기능이 빠졌다고 했습니다. 저는 이게 꼭 버그라기보단 그냥 빼먹은 기능이라고 생각합니다. 물론 React Query는 이 문제도 작은 변경으로 해결해 줍니다.
// cancellation
function Bookmarks({ category }) {
const { isLoading, data, error } = useQuery({
queryKey: ['bookmarks', category],
> queryFn: ({ signal }) =>
> fetch(`${endpoint}/${category}`, { signal }).then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch')
}
return res.json()
}),
})
// 데이터와 에러 상태에 따른 JSX 반환
}
queryFn
에 들어가는 signal
을 fetch
에 넘기면 카테고리 변경 시 요청이 자동으로 중단됩니다. 🎉