- Published on
변이(Mutation) - React Query로 서버 데이터 업데이트하기
변이(Mutation) - React Query로 서버 데이터 업데이트하기
변이(Mutation)와 변이 전역 설정
src/react-query/queryClient.ts
import { createStandaloneToast } from '@chakra-ui/react'
import { QueryClient } from 'react-query'
import { theme } from '../theme'
const toast = createStandaloneToast({ theme })
function queryErrorHandler(error: unknown): void {
const id = 'react-query-error'
const title = error instanceof Error ? error.message : 'error connecting to server'
toast.closeAll()
toast({ id, title, status: 'error', variant: 'subtle', isClosable: true })
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: queryErrorHandler,
staleTime: 600000, // 10 minutes
cacheTime: 900000, // 15 minutes
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
mutations: {
onError: queryErrorHandler,
},
},
})
- 쿼리와 유사하게 오류의 경우 쿼리 클라이언트
defaultOptions의mutation속성에서onError콜백을 설정defaultOptions는 지금까지 업데이트한 쿼리 속성과 변이 속성을 모두 가지고 있음
components/app/Loading.tsx
import { Spinner, Text } from '@chakra-ui/react'
import { ReactElement } from 'react'
import { useIsFetching, useIsMutating } from 'react-query'
export function Loading(): ReactElement {
const isFetching = useIsFetching()
const isMutating = useIsMutating()
const display = isFetching || isMutating ? 'inherit' : 'none'
return (
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="olive.200"
color="olive.800"
role="status"
position="fixed"
zIndex="9999"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
display={display}
>
<Text display="none">Loading...</Text>
</Spinner>
)
}
useIsFetching과 유사하지만 변이 호출 중 현재 해결되지 않은 것이 있는지 알려줌- 따라서
isMutation또는isFetching에 표시되도록Loading컴포넌트를 업데이트
- 따라서
커스텀 변이 훅 - useReserveAppointments
useMutation은useQuery와 매우 유사하지만 다음과 같은 차이점이 있음useMutation은 일회성이므로 캐시 데이터가 없음- 재시도는 구성할 수 있지만, 기본적으로는 없음
useQuery의 경우 기본적으로 세 번 재시도함
- 관련된 데이터가 없으므로 리페치도 일어나지 않음
- 캐시 데이터가 없으므로
isLoading과isFetching이 구분되지 않음isLoading은 데이터가 없을 때 이루어지는 페칭이므로isFetching만 오로지 존재함
onMutate콜백이 존재함- 낙관적(
Optimistic) 쿼리에 사용하여 변이가 실패할 떄 롤백할 수 있도록 이전 상태를 저장하는 데 사용
- 낙관적(
components/appointments/hooks/useReserveAppointments.ts
// @ts-nocheck
import { useMutation } from 'react-query'
import { Appointment } from '../../../../../shared/types'
import { axiosInstance } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useCustomToast } from '../../app/hooks/useCustomToast'
import { useUser } from '../../user/hooks/useUser'
async function setAppointmentUser(
appointment: Appointment,
userId: number | undefined
): Promise<void> {
if (!userId) return
const patchOp = appointment.userId ? 'replace' : 'add'
const patchData = [{ op: patchOp, path: '/userId', value: userId }]
await axiosInstance.patch(`/appointment/${appointment.id}`, {
data: patchData,
})
}
type AppointmentMutationFunction = (appointment: Appointment) => void
export function useReserveAppointment(): AppointmentMutationFunction {
const { user } = useUser()
const toast = useCustomToast()
const { mutate } = useMutation((appointment) => setAppointmentUser(appointment, user?.id))
return mutate
}
useQuery와 다르게 쿼리 키가 필요하지 않음(캐시와 관련 X)setAppointmentUser라는 변이 함수를useMutation hook에 전달하고 객체 구조 할당을 통해 추출한mutate함수를 반환함AppointmentMutationFunction을 반환 타입으로 지정했으므로 다음 아이템에서mutate함수를 위한typescript를 정의
mutate 함수를 위한 Typescript
커스텀 훅에서 mutate 함수를 반환하는 유형
UseMutationFunction<TData = unknown, TError = unknown, TVariable = void, TContext = unknown>TData- 변이 함수 자체에서 반환된 데이터 유형으로써, 변이 함수는 데이터를 반환하지 않으므로
void로 설정
- 변이 함수 자체에서 반환된 데이터 유형으로써, 변이 함수는 데이터를 반환하지 않으므로
TError- 변이 함수에서 발생할 것으로 예상되는 오류(
Error) 유형이므로Error로 설정
- 변이 함수에서 발생할 것으로 예상되는 오류(
TVariablemutate함수가 예상하는 변수 유형으로, 아래 예시의 경우Appointment를 전달
TContext- 낙관적 업데이트 롤백을 위해
onMutate에서 설정하는 유형 - 자세한 내용은 낙관적 업데이트 아이템에서 확인
- 낙관적 업데이트 롤백을 위해
components/appointments/hooks/useReserveAppointments.ts
import { UseMutateFunction, useMutation } from 'react-query'
import { Appointment } from '../../../../../shared/types'
import { axiosInstance } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useCustomToast } from '../../app/hooks/useCustomToast'
import { useUser } from '../../user/hooks/useUser'
async function setAppointmentUser(
appointment: Appointment,
userId: number | undefined
): Promise<void> {
/* */
}
export function useReserveAppointment(): UseMutateFunction<void, unknown, Appointment, unknown> {
const { user } = useUser()
const toast = useCustomToast()
const { mutate } = useMutation((appointment: Appointment) =>
setAppointmentUser(appointment, user?.id)
)
return mutate
}
useMutationFunction을react-query에서 불러온 후 위의 형식에 맞게 인수를 전달Data,Error,Variables,Context순서로 전달
변이 후의 쿼리 무효화하기
- 통상적으로 변이 후에 데이터를 다시 가져옴으로써 관련 쿼리를 무효화한 후 데이터가 최신이 아님을
React Query에 알릴 수 있음
import { UseMutateFunction, useMutation, useQueryClient } from 'react-query'
import { Appointment } from '../../../../../shared/types'
import { axiosInstance } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useCustomToast } from '../../app/hooks/useCustomToast'
import { useUser } from '../../user/hooks/useUser'
async function setAppointmentUser(
appointment: Appointment,
userId: number | undefined
): Promise<void> {
if (!userId) return
const patchOp = appointment.userId ? 'replace' : 'add'
const patchData = [{ op: patchOp, path: '/userId', value: userId }]
await axiosInstance.patch(`/appointment/${appointment.id}`, {
data: patchData,
})
}
export function useReserveAppointment(): UseMutateFunction<void, unknown, Appointment, unknown> {
const { user } = useUser()
const toast = useCustomToast()
const queryClient = useQueryClient()
const { mutate } = useMutation(
(appointment: Appointment) => setAppointmentUser(appointment, user?.id),
{
onSuccess: () => {
queryClient.invalidateQueries([queryKeys.appointments])
toast({
title: 'You have reserved the appointment!',
status: 'success',
})
},
}
)
return mutate
}
queryClient에는invalicateQueries메서드가 있어서 특정한 트리거 후appointment를 변경할 때appointment데이터에 대한 캐시를 무효화하는데 사용됨- 쿼리를 만료(
stale)로 표시하고 쿼리가 현재 렌더링 중이면 리페치(Refetch)를 트리거함
- 쿼리를 만료(
onSuccess핸들러가 관련 쿼리를 무효화하고 이에 따라 데이터 리페치가 트리거 되는 형태- 접두사로
queryKeys상수와Appointments속성이 있는 쿼리를 무효화
쿼리 키 접두사
- 이전 아이템에서
onSuccess핸들러가 관련 쿼리를 무효화했지만, 해당 쿼리키에 해당하는 데이터만 무효화 됨 Appointments에서 변이를 실행할 때 연관된 모든 쿼리를 무효화하는 방식으로 접근invalidateQueries는 정확한 키가 아닌 접두사를 사용하므로 동일한 쿼리 키 접두사로 서로 관련된 쿼리를 설정하면 모든 쿼리를 한 번에 무효화할 수 있음- 다른
queryClient메서드도removeQueries와 같은 쿼리 키 접두사를 사용함
user/hooks/useUserAppointments.ts
import dayjs from 'dayjs'
import { useQuery } from 'react-query'
import type { Appointment, User } from '../../../../../shared/types'
import { axiosInstance, getJWTHeader } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useUser } from './useUser'
async function getUserAppointments(user: User | null): Promise<Appointment[] | null> {
if (!user) return null
const { data } = await axiosInstance.get(`/user/${user.id}/appointments`, {
headers: getJWTHeader(user),
})
return data.appointments
}
export function useUserAppointments(): Appointment[] {
const { user } = useUser()
const fallback: Appointment[] = []
const { data: userAppointments = fallback } = useQuery(
[queryKeys.appointments, queryKeys.user, user?.id],
() => getUserAppointments(user),
{ enabled: !!user }
)
return userAppointments
}
- 이전에는
useQuery의 첫번째 인자로user-appointments를 문자열로 하드코딩 했지만, 쿼리 접두사를 통해[queryKeys.appointments, queryKeys.user, user?.id]를 전달하고 쿼리 키를 업데이트 했으므로useUser에서도 제거할 때 업데이트 해야함
user/hooks/useUser.ts
import { AxiosResponse } from 'axios'
import { useQuery, useQueryClient } from 'react-query'
import type { User } from '../../../../../shared/types'
import { axiosInstance, getJWTHeader } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { clearStoredUser, getStoredUser, setStoredUser } from '../../../user-storage'
async function getUser(user: User | null): Promise<User | null> {
if (!user) return null
const { data }: AxiosResponse<{ user: User }> = await axiosInstance.get(`/user/${user.id}`, {
headers: getJWTHeader(user),
})
return data.user
}
interface UseUser {
user: User | null
updateUser: (user: User) => void
clearUser: () => void
}
export function useUser(): UseUser {
const queryClient = useQueryClient()
const { data: user } = useQuery(queryKeys.user, () => getUser(user), {
initialData: getStoredUser,
onSuccess: (received: User | null) => {
if (!received) {
clearStoredUser()
} else {
setStoredUser(received)
}
},
})
function updateUser(newUser: User): void {
queryClient.setQueryData(queryKeys.user, newUser)
}
function clearUser() {
queryClient.setQueryData(queryKeys.user, null)
queryClient.removeQueries([queryKeys.appointments, queryKeys.user])
}
return { user, updateUser, clearUser }
}
- 하드 코딩된
user-appointments문자열을 마찬가지로queryKeys.appointments,queryKeys.user로 바꿈 removeQueries도 쿼리 키 접두사를 사용하므로 쿼리 키에 두 가지가 첫 항목으로 포함되어 있는 한 사용자ID는 따로 지정할 필요가 없음
변이로 예약 취소하기
components/user/hooks/useUserAppointments
import { UseMutateFunction, useMutation, useQueryClient } from 'react-query'
import { Appointment } from '../../../../../shared/types'
import { axiosInstance } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useCustomToast } from '../../app/hooks/useCustomToast'
async function removeAppointmentUser(appointment: Appointment): Promise<void> {
const patchData = [{ op: 'remove', path: '/userId' }]
await axiosInstance.patch(`/appointment/${appointment.id}`, {
data: patchData,
})
}
export function useCancelAppointment(): UseMutateFunction<void, unknown, Appointment, unknown> {
const queryClient = useQueryClient()
const toast = useCustomToast()
const { mutate } = useMutation(removeAppointmentUser, {
onSuccess: () => {
queryClient.invalidateQueries([queryKeys.appointments])
toast({
title: 'You have canceled the appointment!',
status: 'warning',
})
},
})
return mutate
}
useQueryClient훅에서queryClient를 가져온 후useMutation을 실행하여 반환 값에서mutate함수를 구조 분해useCancelAppointment훅으로 사용자에게 전달할 내용
mutate를 실행하면appoinment인수를 전달하고,useMutation은 해당appoinment인수를 변이 함수(removeAppointmentUser)에 전달onSuccess핸들러에queryKeys.Appoinments로 시작하는 모든 쿼리를 무효화하고toast알림을 띄워줌UseMutateFunction의 타입- 변이 함수는
void를 반환하고 오류 유형은unknown으로 지정 mutate함수에 대한 인수는Appoinment의 유형이고,onMutate의 컨텍스트는 없으므로unknown으로 지정
- 변이 함수는
변이 응답으로 사용자와 쿼리 캐시 업데이트하기
components/user/hooks/usePatchUser.ts
- 패치를 생성해서 서버에서 인증 보호된 라우터와 헤더를 보내는 커스텀 훅
import jsonpatch from 'fast-json-patch'
import { UseMutateFunction, useMutation } from 'react-query'
import type { User } from '../../../../../shared/types'
import { axiosInstance, getJWTHeader } from '../../../axiosInstance'
import { useCustomToast } from '../../app/hooks/useCustomToast'
import { useUser } from './useUser'
async function patchUserOnServer(
newData: User | null,
originalData: User | null
): Promise<User | null> {
if (!newData || !originalData) return null
const patch = jsonpatch.compare(originalData, newData)
const { data } = await axiosInstance.patch(
`/user/${originalData.id}`,
{ patch },
{
headers: getJWTHeader(originalData),
}
)
return data.user
}
export function usePatchUser(): UseMutateFunction<User, unknown, User, unknown> {
const { user, updateUser } = useUser()
const toast = useCustomToast()
const { mutate: patchUser } = useMutation(
(newUserData: User) => patchUserOnServer(newUserData, user),
{
onSuccess: (userData: User | null) => {
if (user) {
updateUser(userData)
toast({
title: 'User updated!',
status: 'success',
})
}
},
}
)
return patchUser
}
useUser훅으로 쿼리 캐시를 업데이트하기 위해user와updateUser를 구조분해 할당updateUser는 사용자 데이터를 가져와서 state를 설정하고 로컬 스토리지와 쿼리 캐시를 설정
mutate함수를useMutation을 호출해서 구조 분해하고patchUser로 이름을 변경patchUserOnServer에newData와originalData로 각각의 인수를 전달originalData는useUser훅의state에 있는 사용자의 데이터가 됨newData는mutate에 전달되는 것(사용자 타입)
onSuccess핸들러를 통해 서버에서 받은 응답으로 사용자를 업데이트onSuccess는 변이 함수에서 반환된 모든 값을 인자로 받음- 취득한 사용자 데이터가 참인경우에만 변이 함수에서 얻은 응답을 가져와
updateUser에 전달함
쿼리 취소 가능하게 만들기
- 낙관적 업데이트의 중요한 부분은 서버로 요청이 전달되는 도중에 취소할 수 있다는 점으로 서버에서 오는 모든 데이터가 캐시의 낙관적 업데이트를 덮어쓰는 일이 없도록 해줌
components/user/hooks/useUser.ts
import { AxiosResponse } from 'axios'
import { useQuery, useQueryClient } from 'react-query'
import type { User } from '../../../../../shared/types'
import { axiosInstance, getJWTHeader } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { clearStoredUser, getStoredUser, setStoredUser } from '../../../user-storage'
async function getUser(user: User | null, signal: AbortSignal): Promise<User | null> {
if (!user) return null
const { data }: AxiosResponse<{ user: User }> = await axiosInstance.get(`/user/${user.id}`, {
headers: getJWTHeader(user),
signal,
})
return data.user
}
interface UseUser {
user: User | null
updateUser: (user: User) => void
clearUser: () => void
}
export function useUser(): UseUser {
const queryClient = useQueryClient()
const { data: user } = useQuery(queryKeys.user, ({ signal }) => getUser(user, signal), {
initialData: getStoredUser,
onSuccess: (received: User | null) => {
if (!received) {
clearStoredUser()
} else {
setStoredUser(received)
}
},
})
function updateUser(newUser: User): void {
/* ... */
}
function clearUser() {
/* ... */
}
return { user, updateUser, clearUser }
}
useUser훅에 있는getUser호출은 낙관적 업데이트 대비 상대적으로 오래 되었을 수 있는 데이터를 서버로부터 가져오는 쿼리이므로 낙관적 업데이트 이후 수동으로 취소할 수 있도록 설정해야 하는 쿼리 함수React Query에서 쿼리를 수동으로 취소하기 위해 표준 자바스크립트 인터페이스인AbortController를 사용AbortSignal객체를DOM요청에 보냄
React Query의 일부 쿼리는 배후에서 자동으로 취소됨- ex) 어떤 쿼리가 실행 중에 기한이 만료(
stale)되거나 비활성(inactive)되는 경우, 또는 쿼리 결과를 보여주는 컴포넌트가 해제되는 경우
- ex) 어떤 쿼리가 실행 중에 기한이 만료(
- 결론적으로
React Query에서Axois쿼리를 수동으로 취소하려면 중단 신호를 전달해야 함useUser훅에서useQuery를 통해 data를 구조분해 할때, 두 번째 인수에signal(AbortSignal)을getUser에 전달signal을Axios인스턴스의 한 구성으로 전달
정리
useQuery(queryKeys.user)
- 사용자 쿼리 키를 지닌 useQuery가 AbortController를 관리
AbortController
- 쿼리 함수인 getUser에 전달되는 signal를 생성
getUser
- AbortController로 부터 받은 신호를 axios에 전달
axios
- 해당 signal에 연결된 상태로써, 취소 이벤트에 대하여 signal을 수신
queryClient.cancelQuery(queryKeys.user)
- cancelQuery 메서드가 실행되면 AbortController를 관리하는 동일한 키에 실행하는 경우 AbortController에 취소 이벤트를 전달
즉, Axios 호출 등 signal을 listen하는 모든 객체는 해당 취소 이벤트를 수신하고 중단함
낙관적 업데이트 작성하기
components/user/hooks/usePatchUser.ts
import jsonpatch from 'fast-json-patch'
import { UseMutateFunction, useMutation, useQueryClient } from 'react-query'
import type { User } from '../../../../../shared/types'
import { axiosInstance, getJWTHeader } from '../../../axiosInstance'
import { queryKeys } from '../../../react-query/constants'
import { useCustomToast } from '../../app/hooks/useCustomToast'
import { useUser } from './useUser'
async function patchUserOnServer(
newData: User | null,
originalData: User | null
): Promise<User | null> {
if (!newData || !originalData) return null
const patch = jsonpatch.compare(originalData, newData)
const { data } = await axiosInstance.patch(
`/user/${originalData.id}`,
{ patch },
{
headers: getJWTHeader(originalData),
}
)
return data.user
}
export function usePatchUser(): UseMutateFunction<User, unknown, User, unknown> {
const { user, updateUser } = useUser()
const toast = useCustomToast()
const queryClient = useQueryClient()
const { mutate: patchUser } = useMutation(
(newUserData: User) => patchUserOnServer(newUserData, user),
{
// onMutate는 onError에 전달되는 컨텍스트를 반환합니다.
onMutate: async (newData: User | null) => {
// 사용자 데이터에 대한 모든 요청을 취소하여 이전 서버 데이터가
// optimistic update를 덮어쓰지 않도록합니다.
queryClient.cancelQueries(queryKeys.user)
// 이전 사용자 값의 스냅샷을 가져오고,
const previousUserData: User = queryClient.getQueryData(queryKeys.user)
// 새 사용자 값으로 optimistic update를 캐시에 업데이트합니다.
updateUser(newData)
// 스냅샷 된 값을 포함하는 컨텍스트 객체를 반환합니다.
return { previousUserData }
},
onError: (error, newData, context) => {
// 캐시를 저장된 값으로 롤백합니다
if (context.previousUserData) {
updateUser(context.previousUserData)
toast({
title: 'Update failed; restoring previous values',
status: 'warning',
})
}
},
onSuccess: (userData: User | null) => {
if (user) {
updateUser(userData)
toast({
title: 'User updated!',
status: 'success',
})
}
},
onSettled: () => {
// 사용자 쿼리를 무효화하여 서버 데이터와 동기화되는지 확인합니다
queryClient.invalidateQueries(queryKeys.user)
},
}
)
return patchUser
}
Referenced