useQuery 의 변경점 이해하기
Dominik의 트위터에 useQuery
의 변경점에 대한 내용이 올라왔습니다.
이미 우리는 콜백(onSuccess
, onError
, onSettled
)을 많이 사용하고 있습니다. 따라서 콜백을 제거하는 것에 대해 부정적으로 생각할 수 있지만, 기존의 콜백은 예상대로 동작하지 않을 수 있기에 변경이 필요했습니다.
API에서 중요한 요소중 하나는 일관성입니다. 사람들은 추상화되어있는 API에서 일관된 응답을 받기를 원합니다. 하지만 useQuery
의 콜백들은 그렇지 않습니다.
Callback 들여다보기
useQuery
에는 성공했을 때, 실패했을 때, 그리고 둘과 관계없이 실행되는 onSuccess
, onError
, onSettled
콜백이 있습니다.
onError
를 사용하는 예시를 보면, onError
콜백을 사용하여 오류가났을 경우 에러 알림을 보여주는 부수효과(side effect)를 실행합니다.
export function useTodos() {
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
onError: (error) => {
toast.error(error.message)
},
})
}
사용자들은 이런 직관적인 API를 좋아합니다. 이러한 콜백을 사용하지 않는다면, 부수효과를 처리하기위해 무시무시한 useEffect
훅을 사용해야하는 것에 두려움을 느낍니다.
export function useTodos() {
const query = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
React.useEffect(() => {
if (query.error) {
toast.error(query.error.message)
}
}, [query.error])
return query
}
예시에 useTodos()
사용하는 함수 컴포넌트의 리렌더링으로 인해 hook을 두 번 호출한다면, 우리는 두 개의 에러 메시지(toast)를 보게됩니다. 사실 이는 onError
콜백을 사용했을 경우도 마찬가지입니다.
중복으로 호출했을 경우, 콜백은 각각의 컴포넌트에서의 값(클로져)을 가지고 실행됩니다.
이 문제를 해결하는 가장 좋은 방법은 QueryClient
를 설정할 때, 전역 cache-level을 사용하는 것 입니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) =>
toast.error(`Something went wrong: ${error.message}`),
}),
})
이렇게 설정한 콜백은 Query당 한 번만 호출합니다. 또한 useQuery
를 호출한 컴포넌트 밖에서 호출되기 때문에 클로져문제가 발생하지 않습니다.
On-demand message 정의하기
Query마다 콜백의 error
내부에 있는 값이 아닌 다른 메시지를 보여주고 싶을 경우, Query의 meta
필드를 사용할 수 있습니다.
meta
객체는 어떠한 정보든 넣을 수 있습니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta.errorMessage) {
toast.error(query.meta.errorMessage)
}
},
}),
})
export function useTodos() {
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
meta: {
errorMessage: 'Failed to fetch todos',
},
})
}
상태 동기화 (State syncing)
콜백 API를 삭제하는 또다른 이유는 많은 사람들이 콜백을 상태 동기화를 하는데 사용하기 때문입니다.
export function useTodos() {
const [todoCount, setTodoCount] = React.useState(0)
const { data: todos } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
//😭 please don't
onSuccess: (data) => {
setTodoCount(data.length)
},
})
return { todos, todoCount }
}
콜백 API가 사용자들에게 위의 예제와 같은 상태 동기화를 하도록 유도한다는 단점이 있습니다. 이러한 상태 동기화도 마찬가지로 앱이 예상하는대로 실행되지 않게합니다.
추가적인 렌더 사이클
setTodoCount
는 또다른 렌더 사이클을 추가합니다. 이는 앱이 필요없는 렌더링을 하게할 뿐만 아니라(문제가 되거나 안될 수도 있음) 잘못된 값을 기자고 렌더링을 할 수 도 있게 합니다.
예를 들어 fetchTodos
는 길이가 5인 목록을 반환한다고 가정해 보겠습니다. 위 코드에서 렌더링 사이클은 세 번입니다.
todos
는undefined
이고 길이는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.todos
는 길이가 5인 배열이 되고todoCount
는 0이 됩니다. 이건useQuery
와onSuccess
는 이미 실행을 마쳤고setTodoCount
는 예약된 중간의 렌더링 사이클입니다. 값들이 동기화되지 않았기 때문에 잘못된 상태입니다.todos
는 길이가 5인 배열이 되고todoCount
는 5가 됩니다. 이게 최종 상태이며 다시 올바른 상태가 되었습니다.
위의 예제는 크게 걱정될 정도는 아니지만, 2번에서 동기화되지 않은 상태로 렌더링 된다는 사실은 끔찍합니다. 이로인해 끔찍한 버그가 생성될 수 있습니다.
이러한 문제를 가장 쉽게 해결하는 방법은 상태를 파생시키는 것 입니다.
export function useTodos() {
const { data: todos } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
const todoCount = todos?.length ?? 0
return { todos, todoCount }
}
이렇게 하면 동기화가 깨질 일이 없습니다.
실행되지 않을 수 있는 콜백
React Query를 점진적으로 적용해갈때 Redux와 같은 상태관리 라이브러리와의 동기화가 필요합니다. 아직 React Query를 모두 사용할 수 없는 부분에 대해 아래의 예시와 같이 사용하곤 합니다.
export function useTodos() {
const { dispatch } = useDispatch()
return useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
onSuccess: (data) => {
dispatch(setTodos(data))
},
})
}
React Query를 비동기 상태 관리자로 사용하는 경우, onSuccess
콜백에서 staleTime
을 정의한 캐싱된 데이터를 읽어오는 것은 심각한 문제를 일으킬 수 있습니다.
filters
와 staleTime
을 추가한 예시를 봐보겠습니다.
export function useTodos(filters) {
const { dispatch } = useDispatch()
return useQuery({
queryKey: ['todos', 'list', { filters }],
queryFn: () => fetchTodos(filters),
staleTime: 2 * 60 * 1000,
onSuccess: (data) => {
dispatch(setTodos(data))
},
})
}
- todos를
done: true
로 필터링하고, React Query가 해당 데이터를 캐시에 저장하고,onSuccess
는 redux에 넣습니다. - todos를
done: false
로 필터링하고, 동일한 과정이 진행됩니다. - todos를 다시
done: true
로 필터링하면 앱이 고장납니다.
이와같은 과정을 통해 onSuccess
가 다시 호출되지 않아 dispatch
가 실행되지 않습니다. 따라서 useTodos
의 데이터는 잘 필터된 데이터이지만, Redux에서 불러오는 값은 필터링되지 않았을 것입니다.
staleTime
에 정의된 시간동안 최신의 데이터를 가져오기 위해 queryFn
을 호출하지 않습니다. 캐시된 데이터를 사용하는 것은 re-fetch를 피할 수 있다는 장점이 있지만, onSuccess
가 호출되지 않아 싱크가 맞지않을 수 있습니다.
onDataChanged
data
가 변경될때마다 호출되는 onDataChanged
를 구현하고자 해도 useEffect
없이 만들 수 없습니다. 하지만 이를 구현하기 위해 또 useEffect
를 사용할 수 밖에 없습니다.
export function useTodos(filters) {
const { dispatch } = useDispatch()
const query = useQuery({
queryKey: ['todos', 'list', { filters }],
queryFn: () => fetchTodos(filters),
staleTime: 2 * 60 * 1000,
})
React.useEffect(() => {
if (query.data) {
dispatch(setTodos(query.data))
}
}, [query.data])
return query
}
이를 통해 구현은 가능하지만, 권장할만한 코드는 아닙니다.
마무리
기존 useQuery
의 콜백(onSuccess
, onError
, onSettled
)은 데이터의 일관성을 보장할 수 없는 문제가 있습니다. 이는 API가 일관성을 가져야한다는 것에 부합하지 않습니다.
또한 이를 useState
를 사용하는 상태와 동기화하여 사용하는 경우가 있는데 절대 그렇게 사용해서는 안됩니다. 이는 개발자가 의도하지 않은 렌더링을 추가하여 잘못된 동작을 초래할 수 있습니다.
이러한 이유로 useQuery
의 API가 변경될 예정입니다.