React Query 라이브러리는 V5에서 Breaking Changes를 감행했습니다. useQuery에서 onSuccess, onError, onSettled와 같은 콜백들이 실패에 가까운 API라 판단하였기 때문입니다.
왜 나쁜가요?
: 예상대로 동작하지 않을 가능성이 높습니다. 즉 나쁜 API인 것이죠
1) 잠재적으로 여러번 호출될 수 있습니다.
export function useTodos() { return useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, onError: (error) => { toast.error(error.message) }, }) }
: useQuery를 사용하여, fetch를 진행하고 성공/에러에 따라 사이드 이펙트가 발생하는 코드를 toast.error(error.message) 와 같은 코드를 실행시킬 수 있습니다.
사람들은 useEffect를 사용하지 않아도 되는, 이 직관적인 콜백을 좋아합니다. 하지만 위 예제처럼 사용하면, useTodos를 두번 호출하면 ,두 개의 에러 알림을 받아야 합니다
두 호출의 중복이 제거되지 않고, 콜백이 지역상태 값을 가둬두므로(클로저) 각각의 컴포넌트 안에서 실행됩니다.
그래서 제거되는 이 API를 피해서 useEffect를 사용할 거라고요? 그것도 추천하지 않습니다. Query당 한 번만 호출되는 콜백을 사용해야 하니까요.
아래처럼 QueryClient를 세팅할 때 전역 캐시 단계에서 콜백을 사용하세요!
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => toast.error(`Something went wrong: ${error.message}`), }), })
만약 Query마다 다른 메시지를 보고주고 싶다면요? 가장 간단하게는 Query의 meta 필드를 사용하세요
meta
- 원하는 정보로 채울 수 있는 임의의 객체
- 전역 콜백 등 Query에 접근할 수 있는 모든 곳에서 사용 가능
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', }, }) }
2) 잘못된 상태 동기화를 유도
여러분 콜백을 사용해서 상태를 동기화하지 마세요. API가 이렇게 유도한다는 걸 알고, 이게 삭제하는 이유이기도 합니다.
export function useTodos() { const [todoCount, setTodoCount] = React.useState(0) const { data: todos } = useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, //😭 제발 이러지 마세요 onSuccess: (data) => { setTodoCount(data.length) }, }) return { todos, todoCount } }
setTodoCount는 또 다른 렌더링 사이클을 끼워넣거든요! 필요 이상으로 자주 렌더링하게 하고, 중간에 낀 렌더링 사이클에 잘못된 값이 포함될 수도 있다는 뜻입니다.
fetchTodos
는 length가 5인 목록을 반환한다고 가정해 보겠습니다. 위 코드에서 렌더링 사이클은 세 번입니다.렌더링 사이클
todos
는undefined
이고 length는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.
todos
는 length가 5인 배열이 되고todoCount
는 0이 됩니다.useQuery
와onSuccess
는 이미 실행을 마쳤고setTodoCount
는 예약된, 중간에 낀 렌더링 사이클입니다. 값들이 동기화되지 않았기 때문에 잘못된 상태입니다.
todos
는 길이가 5인 배열이 되고todoCount
는 5가 됩니다. 이게 최종 상태이며 다시 올바른 상태가 되었습니다.
간단한 해결책은, 상태를 파생시키는 거죠!
export function useTodos() { const { data: todos } = useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, }) const todoCount = todos?.length ?? 0 return { todos, todoCount } }
3) 콜백이 실행되지 않을 가능성
점진적으로 React Query를 사용하고 있어, 리덕스 등에 상태를 동기화해야 하는 상황을 가정해봅시다.
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)) }, }) }
그러나 이렇게 하면 앱이 곧 예상대로 흘러가지 않을 것입니다.
실행되지 않는 onSuccess
- todos를
done: true
로 필터링하고, React Query가 해당 데이터를 캐시에 저장하고,onSuccess
는 redux에 넣습니다.
- todos를
done: false
로 필터링하고, 동일한 과정이 진행됩니다.
- todos를 다시
done: true
로 필터링하면 앱이 고장납니다.
왜냐고요? onSuccess가 다시 호출되지 않기 때문입니다.useTodos의 데이터를 바로 사용하는 곳은 올바르게 필터링된 값을 볼 수 있지만, redux의 데이터를 사용하는 곳은 그렇지 않습니다.
staleTime을 정의하면 React-Query는 최신 데이터를 가져오려 queryFn을 항상 호출하지 않습니다. staleTime: 2 * 60 * 1000 이 시간동안 신선하므로, 2분 동안 데이터는 캐시에서만 읽어옵니다. (이는 과도한 re-fatch를 피할 수 있죠)
하지만 onSuccess는 fetch가 발생해야만 실행됩니다.
동기화가 어긋나는 순간은 곧 발생하고 말것입니다.
요약
API는 간단하고 직관적이며 일관되어야 합니다.useQuery
의 콜백은 이 기준에 부합하는 것처럼 보이도록 위장한 버그 생산기입니다.