[React Query] Section8 - Mutations: Using React Query to Update Data on the Server

이해용·2022년 9월 5일
0
post-thumbnail
post-custom-banner

※ 본 강의에서의 react query는 3버전으로 4버전과는 버전 차이가 있어 코드가 다를 수 있습니다.

Mutations and Query Invalidations

React Query Mutations

  • Use in a more realistic way (server will update!)
    • Invalidate query on mutation so data is purged from the cache (쿼리 무효화는 데이터가 캐시에서 제거되고 리페치(Refetch)를 트리거 할 수 있습니다.)
    • Update cache with data returned from the server after mutation
    • Optimistic update (assume mutation will succeed, rollback if not)

Global Fetching / Error

  • Very similar to queries
  • Errors
    • set onError callback in mutations property of query client defaultOptions
  • Loading indicator
    • useIsMutating is analogous to useIsFetching
    • Update Loading component to show on isMutating
// queryClient.ts

...
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,
    },
  },
});


// Loading.tsx

...
export function Loading(): ReactElement {
  const isFetching = useIsFetching();
  const isMutating = useIsMutating(); // 현재 해결되지 않은 변이 함수의 개수를 정수로 볼 수 있습니다.

  const display = isFetching || isMutating ? 'inherit' : 'none';
...

useMutation

  • very similar to useQuery
  • Differences
    • no cache data (일회성이기 때문에 캐시 데이터가 없습니다.)
    • no retries (기본적으로 재시도가 없습니다. useQuery는 세 번 시도합니다.)
    • no refetch (관련된 데이터가 없으므로 리페치도 없습니다.)
    • no isLoading vs isFetching (캐시 데이터가 없으므로 isLoading과 isFetching이 구분되지 않습니다. isLoading은 데이터가 없을 때 이루어지는 페칭이기 때문입니다. useMutation에는 캐시 데이터 개념이 없으므로 isLoading 개념이 없습니다. isFetching만 있습니다.)
    • return mutate function which actually runs mutation (useMutation은 반환 객체에서 mutate 함수를 반환합니다. 그리고 이것이 변이를 실행하는 데 사용됩니다.
    • onMutate callback (useful for optimistic queries!) (변이가 실패 할 때 복원할 수 있도록 이전 상태를 저장하는데 사용할 것입니다.)
  • reference
// useReserveAppointment.ts

...
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; // TypeScript Error
}

appointment와 userId 모두 필요합니다. 서버의 데이터베이스를 업데이트하려면 어떤 사용자가 어떤 예약을 했는지 알아야합니다.

TypeScript: Returning mutate Function

Type for returning mutate function from custom hook

useMutateFunction<TData = unknown, TError = unknown, TVariables = void, TContext = unknown>
  • Data Type returned by mutation function
    Example: void
    (변이 함수 자체에서 반환된 데이터 유형입니다. 이 경우 변이 함수는 데이터를 반환하지 않습니다. 따라서 void로 설정합니다.)

  • Error type thrown by mutation function
    Example: Error
    (변이 함수에서 발생할 것으로 예상되는 오류(Error) 유형입니다.

  • mutate function variables type
    Example: Appointment
    (mutate 함수가 예상하는 변수 유형입니다.)

  • Context type set in onMutate function for optimistic update rollback
    Example: Appointment
    (낙관적 업데이트 롤백을 위해 onMutate에 설정하는 유형입니다.)

// useReserveAppointment.ts

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;
}

invalidateQueries

  • Invalidate appointments cache data on mutation
    • so user doesn’t have to refresh the page
  • invalidateQueries effects:
    • marks query as stale
    • trigger re-fetch if query currently being rendered

mutate—onSuccess—>invalidateQueries—>re-fetch

// useReserveAppointment.ts

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;
}

invalidateQueries

9월 5일 예약을 해도 흰색으로 예약은 되었으나 Your Appointments에 자동으로 업데이트 되지 않고 새로고침을 해야만 예약 내역이 업데이트 됩니다.

Query Key Prefixes 쿼리 키 접두사

  • Goal: invalidate all queries related appointments on mutation
  • invalidateQueries take a query key prefix (invalidateQueries는 정확한 키가 아닌 접두사를 사용합니다.)
    • invalidate all related queries at once (따라서 동일한 쿼리 키 접두사로 서로 관련된 쿼리를 설정하면 모든 쿼리를 한 번에 무효화 할 수 있습니다.)
    • can make it exact with { exact: true } option (정확한 키로 설정하고 싶다면 exact: true로 설정하면 됩니다.)
    • other queryClient methods take prefix too (like removeQueries) (다른 queryClient 메서드도 removeQueries와 같은 쿼리 키 접두사를 사용합니다.)

Query Key Prefix for Appointments

// useUserAppointments.ts

...
export function useUserAppointments(): Appointment[] {
  const { user } = useUser();

  const fallback: Appointment[] = [];
  const { data: userAppointments = fallback } = useQuery(
    [queryKeys.appointments, queryKeys.user, user?.id], // 'user-appointments' 에서 Query Key Prefix로 변경
    () => getUserAppointments(user),
    { enabled: !!user },
  );

  return userAppointments;
}


// useUser.ts

...
  function clearUser() {
    queryclient.setQueryData(queryKeys.user, null);
    queryclient.removeQueries([queryKeys.appointments, queryKeys.user]); // 'user-appointments' 에서 Query Key Prefix로 변경
  }
...

query key prefix

useMutation to Delete Appointment

  • onSuccess: invalidate appointment queries, show toast
  • This hook doesn’t need useUser
    • unsetting, so don’t need user ID
    • If we were validating auth, we would need to send token to server

나의 시도

// 나의 코드

...
export function useCancelAppointment(): UseMutateFunction<
  void,
  unknown,
  Appointment,
  unknown
> {
  const toast = useCustomToast();
  const queryClient = useQueryClient();

  const { mutate } = useMutation(
    (appointment: Appointment) => { // 에러 발생
      removeAppointmentUser(appointment);
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries([queryKeys.appointments]);
        toast({
          title: 'You have canceled the appointment!',
          status: 'success',
        });
      },
    },
  );

  return mutate;
}

myError

실제 코드

// 실제 코드

export function useCancelAppointment(): UseMutateFunction<
  void,
  unknown,
  Appointment,
  unknown
> {
  const toast = useCustomToast();
  const queryClient = useQueryClient();

  const { mutate } = useMutation(removeAppointmentUser, {
      onSuccess: () => {
        queryClient.invalidateQueries([queryKeys.appointments]); // 모든 쿼리 무효화
        toast({
          title: 'You have canceled the appointment!',
          status: 'warning',
        });
      },
    },
  );

  return mutate;
}

appointment만 가져와서 사용하기 때문에 불필요한 익명함수를 removeAppointmentUser만으로 사용 가능합니다.

변이함수 ⇒ onSueccess

use Cancel Updates

Update Cache from Mutation Response

  • New custom hook usePatchUser (서버에서 사용자를 업데이트하는 데 사용할 메서드입니다.)

    • update query cache with results from mutation server call (usePatchUser변이에 onSuccess 를 넣을 것입니다.)
  • Use the handy updateUser function from useUser

    • will update query cache and localStorage
  • reference

// usePatchUser.ts

...
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;
}

update cache data

Optimistic Updates

  • update cache before response from server (낙관적 업데이트는 서버로부터 응답을 받기 전에 사용자 캐시를 업데이트 하는 것입니다. 우리가 새 값이 무엇인지 알고 있는 경우에요)
  • you’re “optimistic” that the mutation will work (이 경우 우린 변이가 작동할 거라고 낙관합니다. 서버에서 문제 없이 작동할 걸로 추정하는 것이죠)
  • [ v ] cache gets updated quicker (장점은 캐시가 더 빨리 업데이트된다는 건데요 캐시를 업데이트 하기 위해 서버 응답을 기다릴 필요가 없습니다.)
    • especially useful if lots of components rely on it (특히 복수의 컴포넌트가 이 데이터를 사용하는 경우 앱은 사용자에게 훨씬 더 민감하게 반응합니다.)
  • [ x ] what if your optimistic was unfounded and the server update fails? (단점은 서버 업데이트가 실패한 경우 코드가 더 복잡해집니다.)

Rollback / Cancel Query

  • useMutation han as onMutate callback

    • returns context value that’s handed to onError for rollback (콘텍스트 값을 반환하고 onError 핸들러가 이 콘텍스트 값을 인수로 받습니다. 에러가 생기면 onError 핸들러가 호출되고 onError 핸들러가 콘텍스트 값을 받아서 캐시 값을 이전으로 복원할 수 있게 되죠)
    • context value contains previous cache data (이 경우 콘텍스트는 낙관적 업데이트를 적용하기 전의 콘텍스트를 의미합니다.)
  • onMutate function can also cancel refetches-in-progress (캐시를 업데이트할 데이터를 포함하는 특정 쿼리에서 onMutate 함수는 진행 중인 모든 리페치(Refetch)를 취소합니다.)

    • don’t want to overwrite optimistic update with old data from server! (쿼리를 취소하지 않으면 쿼리를 다시 가져올 수 있습니. 리페치가 진행되는 동안 캐시를 업데이트 하는데 서버에서 다시 가져온 이전 데이터로 캐시를 덮어쓰게 되는 거죠. 그래서 낙관적 업데이트를 한 후에 이전 데이터로 캐시를 덮어쓰지 않도록 쿼리를 취소해야 합니다.
  • reference: https://tanstack.com/query/v4/docs/guides/optimistic-updates

Rollback / Cancel Query Flow

User trigger update with mutate

⇒ - Send update to server

  • onMutate

  • Cancel queries in progress

  • Update query cache

  • Save previous cache value

⇒(success?) —yes—> invalidate query

⇒(success?) —no—> onError uses context to roll back cache

Making Query “Cancel-able”

Manually Canceling Query (쿼리 “취소 가능하게” 만들기)

  • React Query uses AbortController to cancel queries (React Query는 AbortController 인터페이스로 쿼리를 취소합니다.)
  • Automatically canceled queries use this signal “behind the scenes” (React Query 일부 쿼리는 배후에서 자동적으로 취소됩니다.)
    • out-of-date or inactive queries (어떤 쿼리가 가동 중에 기한이 만료되거나 비활성화되는 경우 내지는 예를 들면 쿼리 결과를 보여주는 컴포넌트가 해제되는 경우가 있겠죠)
  • Manually canceled axios query: (React Query에서 이 방법을 사용해 axios 쿼리를 수동으로 취소하려면)
    • pass signal to axios via argument to query function (axios에 중단한다는 신호를 전달해야합니다. 이 중단한다는 신호를 쿼리 함수에 인수로 전달됩니다.)
    • https://axios-http.com/docs/cancellation
  • reference: https://tanstack.com/query/v4/docs/guides/query-cancellation
// useUser.ts

...
async function getUser(
  user: User | null,
  signal: AbortSignal, // signal에 AbortSignal 타입 지정
): Promise<User | null> {
  if (!user) return null;
  const { data }: AxiosResponse<{ user: User }> = await axiosInstance.get(
    `/user/${user.id}`,
    {
      headers: getJWTHeader(user),
			signal, // signal을 axios 인스턴스의 한 구성으로 전달할 수 있습니다.
    },
  );
  return data.user;
}
...

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);
        }
      },
    },
  );

...

getUser에 axios 호출을 전달 받기 위해 useQuery가 쿼리 함수에 전달하는 인수로부터 구조분해를 진행하여 이루어집니다.

즉, useQuery는 인수들의 객체를 전달하며 이 신호의 구조를 분해한 뒤 getUser에 두번 째 인수로 전달하는 것입니다.

Aborting via signal

useQuery(queryKeys.user) → AbortController—signal—> getUser—signal—>axios

queryClient.cancelQuery(queryKeys.user)—cancel—>AbortController

사용자 쿼리 키를 지닌 useQuery가 AbortController를 관리합니다. 이 컨트롤러는 쿼리 함수인 getUser에 전달되는 신호를 생성하고 getUser는 해당 신호를 Axios에 전달합니다. 따라서 이제 Axios는 해당 신호에 연결된 상태입니다. 취소 이벤트에 대하여 해당 신호를 수신하는 것이죠.

낙관적 업데이트 작성하기

// usePatchUser.ts

...

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 returns context that is passed to onError
      onMutate: async (newData: User | null) => {
        // cancel any outgoing queries for user data, so old server data
        // doesn't overwrite our optimistic update
        queryClient.cancelQueries(queryKeys.user);

        // snapshot of previous user value
        const previousUserData: User = queryClient.getQueryData(queryKeys.user);

        // optimistically update the cache with new user value
        updateUser(newData);

        // return context object with snapshotted value
        return { previousUserData };
      },
      onError: (error, newData, context) => {
        // roll back cache to saved value
        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: () => {
        // invalidate user query to make sure we're in sync with server data
        queryClient.invalidateQueries(queryKeys.user);
      },
    },
  );

  return patchUser;
}

optimistic updates

Mutations Summary

  • Ways to keep in sync with server after mutation (변이를 완료한 뒤 서버와 동기화한 상태로 데이터를 유지하는 법에 대해 알아봤습니다.)
    • invalidate queries (쿼리를 무효화 하면 캐시를 지우고 리페치를 시작합니다.)
    • update cache with data returned from server with setQueryData 쿼리 클라이언트인 setQueryData를 사용하여 변이 함수로부터 반환된 데이터로 캐시를 업데이트할 수 있습니다.)
    • optimistic updates (페이지 상에서 다수의 영역에 데이터를 사용하는 경우 반응성을 개선하는 낙관적 업데이트를 진행할 수도 있습니다.)
      • send mutation
      • cancel outgoing queries (발신 쿼리를 모두 취소하여 낙관적 업데이트를 덮어쓰는 서버 측의 옛 데이터를 받지 않는 것입니다.)
      • update cache (사용자가 입력한 데이터로 캐시를 낙관적으로 업데이트 합니다.)
      • save previous value (롤백할 경우를 대비하여 기존의 값은 저장해 둡니다.)
      • roll back if necessary (변이가 실패한 경우 롤백을 위해 에러 핸들러에 해당 값을 사용합니다.)

reference
https://www.udemy.com/course/learn-react-query
https://tanstack.com/query/v4/docs/reference/useIsMutating
https://tanstack.com/query/v4/docs/reference/useMutation
https://tanstack.com/query/v4/docs/guides/mutations
https://tanstack.com/query/v4/docs/guides/query-keys
https://tanstack.com/query/v4/docs/guides/query-invalidation#query-matching-with-invalidatequeries
https://tanstack.com/query/v4/docs/guides/updates-from-mutation-responses
https://tanstack.com/query/v4/docs/guides/optimistic-updates
https://tanstack.com/query/v4/docs/guides/query-cancellation
https://axios-http.com/docs/cancellation
https://developer.mozilla.org/ko/docs/Web/API/AbortController
https://developer.mozilla.org/en-US/docs/Web/API/AbortController

profile
프론트엔드 개발자입니다.
post-custom-banner

0개의 댓글