React Query를 잘 써보기 위한 탐구 [1]
최근에 회사에서 사용하는 React Query를 더 잘 쓰기 위한 논의를 하다가 이야기가 나온 것이 React Query의 기여자 중 한 분인 tkdodo의 React Query 포스팅 시리즈였습니다. React Query를 더 잘 쓰기 위한 고민들과 프랙티스들이 많아 정말 좋은 참고자료였는데요.
요즘 요 포스팅 타래를 쭉 읽어보고 있습니다. 현재 글은 23개인데, 대충 절반 정도인 11개의 포스팅을 읽고 각각의 글에 대한 간단한 독후감을 작성해 봤습니다. 내용을 요약/인용하고, 제 느낌이나 생각을 덧붙이는 방식입니다. 나중에 다 읽고 2탄도 써보겠습니다.
1. practical react query
So it seems that we have always been treating this server state like any other client state. Except that when it comes to server state (think: A list of articles that you fetch, the details of a User you want to display, ...), your app does not own it. We have only borrowed it to display the most recent version of it on the screen for the user. It is the server who owns the data.
To me, that introduced a paradigm shift in how to think about data. If we can leverage the cache to display data that we do not own, there isn't really much left that is real client state that also needs to be made available to the whole app.
데이터를 소유권의 개념으로 구분한 것이 명쾌하다고 느껴졌습니다. 이것은 처음 RQ를 접했을때부터 느낀 것인데요.
서버에서 데이터를 빌려(borrow)서 가장 최신의 버전을 보여주는 책임만이 클라이언트에 존재한다는 말로, 데이터 소유권에 따른 책임을 명확히 하고 있습니다. 이 구분이 라이브러리 존재의 시작점이 되기도 하고, 개발의 로드맵이 어디에 집중할지도 명시가 되는 것 같습니다.
(Don't use the queryCache as a local state manager) If you tamper with the queryCache (
queryClient.setQueryData
), it should only be for optimistic updates or for writing data that you receive from the backend after a mutation. Remember that every background refetch might override that data, so use something else for local state.
명시적으로 데이터를 set하는 것을 최대한 지양하라는 말로 해석이 되었습니다. 맥락으로 볼때 앞에서 말했던 상태의 분리, 그리고 background fetching이라는 동작이 최대한 의도된 바 그대로 이루어지게 하려는 것 같습니다.
"use something else"로 제시한게 react의 state, zustand, redux인데 RQ 역시 구조적으로는 Provider가 있는 탑다운 방식의 상태관리를 하고 있으니 이 맥락에서 맞는 도구들을 제시한게 아닌가 싶습니다.
2. React Query Data Transformations
On the backend
일하다보면 백엔드에서 데이터를 바꿔줄 수 있다는 것을 가끔 잊어먹기도 하기에 좋은 포인트를 짚어줬다고 생각합니다. 역시 다 내가 해야 하는 것은 아닙니다.
어떤 데이터 변경(transform)들은 백엔드에서 하는게 더 맞을 때도 있습니다. 또한 데이터를 빌리는 클라이언트가 여러개라면 백엔드에서 단일성을 보장하는게 더 좋을 때도 있습니다. 백엔드의 데이터를 변경하는 로직이 클라이언트에 많이 들어있을수록 어디서 뭔 일이 일어나는지 판단하기 항상 어려워졌습니다.
But if you pass a selector, you are now only subscribed to the result of the selector function. This is quite powerful,
selector는 query가 watch해야할 변경점을 좁히는 역할도 같이 합니다.
사실 이 글에는 데이터를 변경하는 장소(backend, queryFn, render function, select) 이렇게 보여주고 select
가 가장 단점이 적어 보이지만
경험상 백엔드의 응답을 query의 결과값에 그대로 넣어야 좋은 상황이 꽤 많았습니다. query와 특정 백엔드 데이터를 1:1로 대응시키는게 더 단순한 멘탈 모델이고, 여러 작업자가 문서화 등으로 데이터의 생김새를 쉽게 알 수 있는 백엔드의 응답값이라는 공동의 이해 위에서 작업을 하는게 더 나았어요. select
는 쉽게 암묵지가 되거나 select
가 설정된 query를 훅으로 말아서 사용하면 특정 기능에서 원하는 데이터를 가져올 수가 없어서 중복 패칭을 유발할 수도 있었습니다.
그래서 결국 백엔드의 응답을 query에 살려두고, 이 query에서 나온 응답을 select
해주는 경우가 확장성이 더 나을 수도 있다는 생각은 들었습니다. 이런 전제라면 원래의 query에서 나온 응답을 select
하기 위해 또 query를 쓰는게 오버킬처럼 느껴질 수도 있어서 render function에서의 데이터 transform도 빈번하게 이뤄질 수 있을 것 같네요.
3. React Query Render Optimizations
Render optimizations are an advanced concept for any app. React Query already comes with very good optimizations and defaults out of the box, and most of the time, no further optimizations are needed.
I'd take an "unnecessary re-render" over a "missing render-that-should-have-been-there" all day every day.
React와 관련된 최적화 썰에서 항상 "최적화를 하자"와 "발적화를 하지말자"는 항상 같이 나오는 말인 것 같습니다. Kent Dodds의 좋은 글도 레퍼런스를 걸어놨습니다.
I'm quite proud of this feature, given that it was my first major contribution to the library. If you set
notifyOnChangeProps
to'tracked'
, React Query will keep track of the fields you are using during render, and will use this to compute the list.
v4부터 기본으로 적용되었던 tracked query는 정말 잘 만든 기능이라고 생각합니다. 사용자가 신경써야 할 것을 적절하게 라이브러리 단으로 가져오며 바깥으로 나가는 인터페이스는 설정값 딱 하나만 만들었습니다.
필요한 속성만 뽑아내 쓰라는 것 이상의 무언가를 이해하는 것이 앱 운영이나 라이브러리 사용에 필수적인 것도 아닙니다. 상당한 엣지케이스라도 그럴 필요가 없을 것 같은 느낌이 드네요.
요런 특성의 기능들을 잘 파악해서 라이브러리 안으로 깔끔하게 가져오는 것이 라이브러리 구현 관점의 미덕이 아닌가... 하는 생각이 듭니다.
- If you use object rest destructuring, you are effectively observing all fields. Normal destructuring is fine, just don't do this:
전개 연산자로 디스트럭쳐링을 하면 tracked query의 효과가 없어진다고 합니다.
4. Status Checks in React Query
success
: Your query was successful, and you havedata
for iterror
: Your query did not work, and anerror
is setpending
: Your query has no data
status
에 대한 설명입니다. v4부터 바뀌었던 status
와 fetchStatus
의 분화로 설명하기 좋아진 것 같습니다. status
는 data
의 유무를 기준으로 하기에 멘탈 모델이 단순합니다. v5부터는 loading
이 pending
으로 이름이 바뀌었습니다. 주로 pending이 데이터가 없음을 더 잘 나타내는 네이밍이라는 이유라서 그런 듯 합니다. promise의 pending 상태를 생각해보면 말이 됩니다.
This is even more relevant when we take into account that React Query will retry failed queries three times per default with exponential backoff, so it might take a couple of seconds until the stale data is replaced with the error screen. If you also have no background fetching indicator, this can be really perplexing. This is why I usually check for data-availability first:
페치 실패시 retry
는 3번이 기본값이므로 에러 발생시 에러 뷰를 띄운다면 에러를 화면에서 알 수 있는 시점이 꽤나 늦을 수 밖에 없습니다. 그래서 에러나 로딩 상태보다 데이터가 available한지를 먼저 알아내서 분기를 하는게 더 좋은 프랙티스라고 이야기하는 부분입니다.
제시된 예제처럼 하면 리페치시 에러 유무에 상관없이 데이터가 있으면 무조건 그 데이터를 보여줄 것입니다.
// data-first
const todos = useTodos();
if (todos.data) {
return <div>{todos.data.map(renderTodo)}</div>;
}
if (todos.error) {
return 'An error has occurred: ' + todos.error.message;
}
return 'Loading...';
5. Testing React Query
백엔드 API를 모킹할 수 있는 좋은 방식을 설명하고 그 다음에 RQ에 한정된 부분을 설명하는 글 전개 방식이 명확해서 좋았습니다. 그리고 테스트 꿀팁들이 있어요.
It's one of the most common "gotchas" with React Query and testing: The library defaults to three retries with exponential backoff, which means that your tests are likely to timeout if you want to test an erroneous query. The easiest way to turn retries off is, again, via the
QueryClientProvider
.
테스트용 QueryClient
는 retry
옵션을 끄자는 말입니다.
The best advice I can give you for this problem is: Don't set these options on
useQuery
directly. Try to use and override the defaults as much as possible, and if you really need to change something for specific queries, use queryClient.setQueryDefaults.
테스트의 상황에 맞게 특정 query의 옵션을 바꿔줄 수 있는 API인 setQueryDefaults
가 있습니다. 이건 테스트가 아닌 애플리케이션에서 바로 사용하면 설정값이 어디서 들어왔는지 모르게 되서 작업자의 뇌정지를 유발할 수도 있을 것 같다는 생각이 들었습니다.
Since React Query is async by nature, when running the hook, you won't immediately get a result. It usually will be in loading state and without data to check
당연한 이야기지만 비동기로 테스트 결과값을 기대해야 한다고 합니다.
6. React Query and TypeScript
If you (like me) like to keep your api layer separated from your queries, you'll need to add type definitions anyways to avoid implicit any, so React Query can infer the rest:
Since React Query is not in charge of the function that returns the Promise, it also can't know what type of errors it might produce. So
unknown
is correct.
타입 추론을 최대한 이용하려면 queryFn
에 들어가는 함수에 리턴 타입을 주고, 에러 타입은 제네릭을 주지 않으면 타입을 알 수 없으니(unknown
) 직접 핸들하는 방식으로 사용해야 합니다.
It will further help TypeScript to narrow types when using the status field or one of the status booleans, which it cannot do if you use destructuring:
useQuery
의 리턴값은 설정값에 따라 추론되니 디스트럭쳐링 없이 사용하는게 타입 추론에는 더 이점이 있습니다. 근데 필요한 프로퍼티만 디스트럭쳐링 해야 잘 작동한다는 tracked query 쪽 내용이랑 좀 상충되는거 같네요.
7. Using WebSockets with React Query
React Query doesn't have anything built-in specifically for WebSockets. That doesn't mean that WebSockets are not supported or that they don't work well with the library. It's just that React Query is very agnostic when it comes to how you fetch your data: All it needs is a resolved or rejected
Promise
to work - the rest is up to you.
React Query가 명시적으로 웹소켓을 지원하기위한 특정 구현을 가지고 있지 않음을 말하는데, promise 기반으로만 동작시켜주면 되기 때문입니 다. 사실 이런 컨셉 때문에 데이터를 어디에서 어떻게 받아오든 상관이 없겠죠. 요걸 짚어준게 좋았습니다.
This goal overlaps a lot with WebSockets, which update your data in real-time. Why would I need to refetch at all if I just manually invalidated because the server just told me to do so via a dedicated message?
웹 소켓은 이벤트 기반으로 명시적으로 데이터를 업데이트, invalidate하기 때문에 시간에 따라 stale해짐을 상정할 필요가 없을 수도 있습니다. 따라서 staleTime
을 Inifinity
로 잡아도 괜찮을 수 있다는 말이 되겠습니다.
8. Effective React Query Keys
If you have some state that changes your data, all you need to do is to put it in the Query Key, because React Query will trigger a refetch automatically whenever the key changes. So when you want to apply your filters, just change your client state:
refetch
를 핸들러에 넣어서 페치하려고 하지 말고, queryKey
를 바꿔 페치가 다시 이루어지도록 하라는 말인데요. refetch
는 파라미터를 바꿔서 다시 요청하려고 할 때 쓰려고 만든 게 아니라고 합니다.
query를 선언적으로 작성하고 다루는데 필요한 사용 방식으로 이해됩니다. Treat the query key like a dependency array 에서 이야기 했던 설명과 이어지기도 합니다.
Manual Interactions with the Query Cache are where the structure of your Query Keys is most important. Many of those interaction methods, like invalidateQueries or setQueriesData support Query Filters, which allow you to fuzzily match your Query Keys.
명시적으로 query cache를 쉽게 다루기 위해 query filter가 있다는건 오래 전부터 알고 있었습니다.
그런데 뭔가 실제 개발할때 많이 (혹은 엄밀하게) 써보질 않았습니다. 앱 내에서 queryKey
를 제대로 관리하지 못하는 경우, 혹은 뭉뚱그려서 그냥 이것저것 다 invalidate 시켰던 경우가 생각났습니다. 그만큼 사용자 경험을 저하시켰을 텐데 반성이 됩니다.
I have found these strategies to work best when your App becomes more complex, and they also scale quite well. You definitely don't need to do this for a Todo App 😁.
tkdodo님의 기술 글쓰기가 편안하다고 느껴지는 부분들 중 하나는, 이렇게 특정 프랙티스에 대한 전제를 아주 잘 짚고 넘어가는 부분이 많아서인 것 같습니다.
Yes, Query Keys can be a string, too, but to keep things unified, I like to always use Arrays. React Query will internally convert them to an Array anyhow, so:
v4로 넘어오면서 배열 queryKey
만 쓸 수 있도록 되었는데, string을 허용할 때에도 queryKey
는 배열로 변형되는 구조였다고 합니다.
Structure your Query Keys from most generic to most specific, with as many levels of granularity as you see fit in between. Here's how I would structure a todos list that allows for filterable lists as well as detail views:
['todos', 'list', { filters: 'all' }][('todos', 'list', { filters: 'done' })][
('todos', 'detail', 1)
][('todos', 'detail', 2)];
인지적으로도 더 좋은 방법으로 보여지네요. 제공된 예제도 queryKey
가 이런 식으로 짜지지 않았다면 더 선언적으로 보이지 않았을 것 같습니다.
That's why I recommend one Query Key factory per feature. It's just a simple object with entries and functions that will produce query keys, which you can then use in your custom hooks. For the above example structure, it would look something like this:
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
데이터에 특성에 맞게 queryKey
팩토리를 만들라는 말인데 좋은 방법처럼 보입니다. 특히 커다란 프로젝트의 경우에는 더더욱 그럴 것 같아요.