TanStack Query queryOptions 패턴: 커스텀 훅 방식과 무엇이 다른가

queryOptions 패턴은 queryKey와 queryFn을 한 객체로 묶어 돌려주는 함수를 팩토리에 두는 방식이다. 키만 팩토리에 두고 useQuery를 커스텀 훅 안에 가두는 방식과 달리, 같은 옵션 객체를 여러 훅과 prefetchQuery 같은 queryClient 메서드가 그대로 받고 키에 데이터 타입이 붙는다.

React

기존 방식: 키 팩토리와 커스텀 훅

  1. 쿼리 키 팩토리는 쿼리 키를 만드는 함수들을 한 객체에 모아 둔 것이다.

  2. 쿼리 키를 손으로 적으면 오타가 나기 쉬우므로, 키를 만드는 자리를 한 곳으로 모은다.

  3. 그리고 useQuery 호출은 useTodo 같은 커스텀 훅으로 분리해, 컴포넌트가 키와 fetchTodo를 몰라도 되게 한다.

    // 키만 만드는 팩토리: queryFn은 여기 없다
    export const todoKeys = {
      all: ['todos'] as const,
      lists: () => [...todoKeys.all, 'list'] as const,
      list: (filters: Filters) => [...todoKeys.lists(), filters] as const,
      detail: (id: number) => [...todoKeys.all, 'detail', id] as const,
    }
    
    // 키와 queryFn은 커스텀 훅 안에서 처음 만난다
    export function useTodo(id: number) {
      return useQuery({
        queryKey: todoKeys.detail(id),
        queryFn: () => fetchTodo(id), // fetchTodo는 Promise<Todo>를 돌려준다
      })
    }
  4. 이 구조에서 키와 queryFn이 함께 묶이는 장소는 useTodo 함수 본문 하나뿐이다.

  5. 그러면 같은 데이터를 useTodo와 다른 방식으로 받아야 할 때 어떤 일이 생기는지 따라가 볼 필요가 있다.

커스텀 훅에 묶인 옵션이 막히는 곳

  1. useTodo는 본문에서 useQuery를 부르도록 고정되어 있다.

  2. useSuspenseQuery는 데이터가 올 때까지 컴포넌트 렌더링을 멈추고, 가장 가까운 <Suspense>의 fallback을 대신 보여 주는 훅이다.

  3. 만약 같은 데이터를 이렇게 Suspense로 받으려면 useQuery 대신 useSuspenseQuery를 불러야 한다.

  4. 그런데 React의 Rules of Hooks에 따라 훅은 조건문 안에서 부를 수 없으므로, useTodo가 인자를 받아 두 훅 중 하나를 고르게 만들 수 없다.

  5. 결국 useSuspenseTodo라는 훅을 하나 더 만들고, 키와 queryFn을 다시 적게 된다.

    export function useTodo(id: number) {
      return useQuery({
        queryKey: todoKeys.detail(id),
        queryFn: () => fetchTodo(id),
      })
    }
    
    // 부르는 훅만 다르고 옵션은 useTodo와 같은 내용을 복사했다
    export function useSuspenseTodo(id: number) {
      return useSuspenseQuery({
        queryKey: todoKeys.detail(id), // 중복
        queryFn: () => fetchTodo(id), // 중복
      })
    }
  6. 두 훅의 옵션은 복사본이라 따로 바뀔 수 있어서, 한쪽에만 옵션을 추가하면 같은 키에 서로 다른 설정이 붙는다.

  7. 정리하면 기존 방식에서 키는 재사용되지만, 키와 queryFn을 하나로 묶은 단위는 그것을 감싼 훅 안에 갇혀 재사용되지 않는다.

queryOptions 패턴: 옵션 객체를 팩토리가 만든다

  1. queryOptions 패턴은 팩토리가 useQuery에 넘길 옵션 객체 전체를 돌려주게 바꾼다.

  2. 옵션 객체는 TanStack Query v5의 queryOptions 헬퍼로 감싸서 만든다.

    import { queryOptions } from '@tanstack/react-query'
    
    export const todoQueries = {
      // 무효화 범위로만 쓰는 상위 키는 그대로 키만 돌려준다
      all: () => ['todos'] as const,
      lists: () => [...todoQueries.all(), 'list'] as const,
      
      // 실제로 데이터를 가져오는 단위는 키와 queryFn을 한 객체로 묶는다
      list: (filters: Filters) =>
        queryOptions({
          queryKey: [...todoQueries.lists(), filters],
          queryFn: () => fetchTodos(filters),
        }),
        
      detail: (id: number) =>
        queryOptions({
          queryKey: [...todoQueries.all(), 'detail', id],
          queryFn: () => fetchTodo(id),
          staleTime: 30_000, // 이 쿼리에만 적용할 신선도 기준
        }),
    }
  3. 이제 키, queryFn, staleTime 같은 설정이 todoQueries.detail(id)라는 한 번의 호출 결과에 모두 들어 있다.

  4. 쓰는 쪽은 이 객체를 그대로 넘기기만 한다.

    // 컴포넌트 A
    function TodoDetail({ id }: { id: number }) {
      const { data } = useQuery(todoQueries.detail(id))
    }
    
    // 컴포넌트 B: Suspense가 필요하면 훅만 바꾸고 옵션은 같은 것을 넘긴다
    function TodoDetailSuspense({ id }: { id: number }) {
      const { data } = useSuspenseQuery(todoQueries.detail(id))
    }
    
    // 컴포넌트 C: 개수가 정해지지 않은 여러 쿼리도 같은 옵션 객체를 배열로 넘긴다
    function TodoCards({ ids }: { ids: number[] }) {
      const results = useQueries({ queries: ids.map((id) => todoQueries.detail(id)) })
    }
    
    // 컴포넌트 D: 이 컴포넌트만 다른 설정은 펼친 뒤 덧붙인다
    function TodoTitle({ id }: { id: number }) {
      // 펼친 뒤에 적은 staleTime이 마지막에 놓이므로 팩토리의 30_000도 덮어쓴다
      const { data } = useQuery({ ...todoQueries.detail(id), staleTime: 0, select: (todo) => todo.title })
    }
    
    // 컴포넌트 밖: 라우트 로더처럼 훅을 못 쓰는 곳에서 미리 받아 둘 때도 같은 객체를 queryClient 메서드에 넘긴다
    await queryClient.prefetchQuery(todoQueries.detail(1))
  5. 기존 방식에서 따로 만들어야 했던 useSuspenseTodo가 필요 없어지는 이유는, 재사용 단위가 훅에서 평범한 객체로 바뀌었기 때문이다.

  6. 훅은 본문에서 부를 훅이 정해져 있지만, 객체는 어느 훅에든 인자로 넘길 수 있다.

    // before: 재사용 단위가 훅이다
    // 훅 안에서 useQuery를 부르므로, Suspense용은 훅을 하나 더 만들어 옵션을 복사한다
    export function useTodo(id: number) {
      return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) })
    }
    export function useSuspenseTodo(id: number) {
      return useSuspenseQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) })
    }
    function Before() {
      const a = useTodo(1)
      const b = useSuspenseTodo(1)
    }
    
    // after: 재사용 단위가 객체다
    // 객체는 어떤 훅도 부르지 않으므로, 부르는 쪽이 훅을 고르고 같은 객체를 넘긴다
    const detail = (id: number) => queryOptions({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id) })
    function After() {
      const a = useQuery(detail(1))
      const b = useSuspenseQuery(detail(1))
    }

헬퍼 없이 옵션 객체만 빼 두면 안 되나: queryOptions 를 쓰는 이유 = 타입

  1. 옵션을 복사하는 문제는 queryOptions 없이도 풀 수 있다.

  2. 키와 queryFn을 담은 평범한 객체를 돌려주는 함수를 두면, 그 객체도 useQuery와 useSuspenseQuery에 그대로 넘길 수 있다.

    // queryOptions로 감싸지 않은 평범한 함수
    const plainDetail = (id: number) => ({
      queryKey: ['todos', 'detail', id],
      queryFn: () => fetchTodo(id),
    })
    
    // 컴포넌트 A
    const a = useQuery(plainDetail(1))
    // 컴포넌트 B: 같은 객체를 다른 훅에 넘겨도 동작한다
    const b = useSuspenseQuery(plainDetail(1))
  3. 실제로 @tanstack/react-query v5 소스에서 queryOptions의 구현은 받은 인자를 그대로 돌려주는 한 줄이다.

    // packages/react-query/src/queryOptions.ts
    // 위에 오버로드 선언 세 개가 있고, 아래가 실제로 실행되는 구현이다
    export function queryOptions(options: unknown) {
      return options // 복사하지도 않고, 값을 바꾸지도 않는다
    }
  4. 그래서 queryOptions가 더하는 런타임 비용은 함수 호출 한 번이고, 번들에 들어가는 코드도 이 함수 하나다.

  5. 렌더링마다 옵션 객체가 새로 만들어지는 것은 useQuery에 객체 리터럴을 바로 적던 기존 방식에서도 같다.

  6. 만들어지는 객체와 쿼리 동작은 두 방식이 같고, 함수 호출 한 번 말고는 타입 검사에서만 차이가 난다.

  7. 반환 타입을 따로 지정하지 않은 평범한 함수에서 staleTime을 stalTime으로 잘못 적으면, 정의하는 곳에서도 useQuery에 넘기는 곳에서도 컴파일 오류가 나지 않는다.

  8. TypeScript는 객체 리터럴을 타입이 정해진 대상(타입이 정해진 인자, 타입을 적은 변수, satisfies)과 바로 대조할 때만 모르는 속성을 오류로 잡는데, 함수가 돌려준 객체는 useQuery의 인자와 대조되는 시점에 이미 리터럴이 아니기 때문이다.

    // useQuery의 인자 자리에 객체 리터럴을 바로 적었다: 모르는 속성 stalTime이 오류가 된다
    useQuery({ queryKey: ['todos', 'detail', 1], queryFn: () => fetchTodo(1), stalTime: 30_000 }) // 컴파일 오류
    
    // 같은 객체를 타입 없는 변수에 담았다가 넘겼다: 인자와 대조될 때는 리터럴이 아니므로 검사하지 않는다
    const options = { queryKey: ['todos', 'detail', 1], queryFn: () => fetchTodo(1), stalTime: 30_000 }
    useQuery(options) // 통과
  9. queryOptions는 인자 타입이 useQuery의 옵션 타입이므로, 같은 실수를 정의하는 줄에서 바로 잡는다.

    // 평범한 함수: 오류가 나지 않고, staleTime은 기본값 0으로 동작한다
    const plainDetail = (id: number) => ({
      queryKey: ['todos', 'detail', id],
      queryFn: () => fetchTodo(id),
      stalTime: 30_000, // 오타지만 통과한다
    })
    useQuery(plainDetail(1)) // 여기서도 통과한다
    
    // queryOptions: 객체 리터럴을 인자 자리에 바로 적으므로 모르는 속성이 오류가 된다
    const detail = (id: number) =>
      queryOptions({
        queryKey: ['todos', 'detail', id],
        queryFn: () => fetchTodo(id),
        stalTime: 30_000, // 컴파일 오류: Did you mean to write 'staleTime'?
      })
  10. 물론 이 검사는 평범한 함수에서도 TypeScript의 satisfies로 옵션 타입을 지정하면 얻을 수 있다.

  11. satisfies는 값의 타입을 바꾸지 않고, 그 값이 지정한 타입에 맞는지만 검사하는 연산자다.

    // queryOptions 없이 오타를 잡는다: 오류 메시지도 한 줄이다
    const satisfiedDetail = (id: number) =>
      ({
        queryKey: ['todos', 'detail', id],
        queryFn: () => fetchTodo(id),
        stalTime: 30_000, // 컴파일 오류: Did you mean to write 'staleTime'?
      }) satisfies UseQueryOptions<Todo>
  12. 그러나 이렇게 만든 객체의 키에는 데이터 타입이 붙지 않고, 키에 데이터 타입이 붙는 것은 queryOptions로 감쌀 때뿐이다.

    // 둘 다 키로 캐시를 읽는다
    const a = queryClient.getQueryData(satisfiedDetail(1).queryKey) // unknown: 무엇이 저장됐는지 모른다
    const b = queryClient.getQueryData(todoQueries.detail(1).queryKey) // Todo | undefined
  13. 반대로 queryOptions의 오류 메시지는 satisfies보다 길다.

  14. queryOptions는 initialData가 있는지, queryFn에 skipToken을 넣을 수 있는지에 따라 오버로드가 세 개로 나뉘어 있어서, 오타 하나에 오버로드마다 같은 내용의 오류가 세 번 나온다.

    error TS2769: No overload matches this call.
      Overload 1 of 3, ... 'stalTime' does not exist in type 'DefinedInitialDataOptions<...>'. Did you mean to write 'staleTime'?
      Overload 2 of 3, ... 'stalTime' does not exist in type 'UnusedSkipTokenOptions<...>'. Did you mean to write 'staleTime'?
      Overload 3 of 3, ... 'stalTime' does not exist in type 'UndefinedInitialDataOptions<...>'. Did you mean to write 'staleTime'?
  15. 결국 옵션 복사는 평범한 함수로도 풀리고, 오타 검사는 satisfies로도 얻으므로, queryOptions만 주는 것은 키에 붙는 데이터 타입이다.

키에 데이터 타입이 붙는다

  1. 기존 커스텀 훅 방식에는 복사 말고 불편함이 하나 더 있다.

  2. 컴포넌트 밖에서 캐시를 직접 읽을 때는 queryClient.getQueryData, 고칠 때는 queryClient.setQueryData를 쓰고, 둘 다 첫 인자로 쿼리 키를 받는다.

  3. 그런데 TypeScript가 아는 todoKeys.detail(1)의 타입은 배열의 모양뿐이고, 이 키로 Todo가 저장된다는 정보는 어디에도 없다.

  4. 그래서 키만 받은 getQueryData는 돌려줄 값의 타입을 몰라 unknown을 돌려준다.

    // TypeScript가 아는 키의 타입: readonly ['todos', 'detail', number]
    const key = todoKeys.detail(1)
    
    // 키에 데이터 타입 정보가 없으므로 unknown이 된다
    const data = queryClient.getQueryData(key)
    data.title // 컴파일 오류: 'data' is of type 'unknown'
  5. 이 값을 쓰려면 제네릭으로 반환 타입을 직접 적어야 하는데, 컴파일러는 적은 타입이 실제로 저장된 데이터와 맞는지 확인하지 않는다.

    // 제네릭으로 Todo를 적으면 Todo | undefined가 된다
    const todo = queryClient.getQueryData<Todo>(todoKeys.detail(1))
    
    // 캐시에는 Todo가 들어 있지만 User로 잘못 적어도 컴파일 오류가 나지 않는다
    const user = queryClient.getQueryData<User>(todoKeys.detail(1))
    user?.name // 컴파일은 통과하고, 런타임에는 Todo에 name이 없어 undefined다
  6. 정리하면 기존 방식에서는 「이 키에는 Todo가 저장된다」는 사실을 사람이 기억해 제네릭으로 옮겨 적는다.

  7. queryOptions 패턴은 이 사실을 키의 타입 안에 넣는다.

  8. TypeScript가 보는 todoQueries.detail(1).queryKey의 타입은 다음과 같다.

    // 앞부분: 기존 키와 같은 배열 모양
    // & 뒷부분: queryFn이 돌려주는 Todo를 담은 속성 (에러 타입을 담는 속성은 줄였다)
    (string | number)[] & { [dataTagSymbol]: Todo }
  9. &는 교차 타입으로, 양쪽 타입의 속성을 모두 가진 타입을 뜻한다.

  10. queryOptions의 반환 타입 선언이 queryKey에 이 속성을 더하고, Todo는 queryFn의 반환 타입에서 추론된다.

  11. 이 속성은 타입 선언에만 있고, queryOptions는 인자를 그대로 돌려주므로 런타임의 키 배열에는 들어가지 않는다.

  12. TanStack Query는 이렇게 데이터 타입을 붙인 키 타입을 DataTag라고 부르고, getQueryData와 setQueryData는 키에 이 속성이 있으면 그 안의 타입을 쓴다.

    // 제네릭 없이 Todo | undefined로 추론된다
    const todo2 = queryClient.getQueryData(todoQueries.detail(1).queryKey)
    todo2?.title // 통과
    
    // setQueryData도 같은 키를 읽으므로, Todo와 모양이 다른 값을 넣으면 컴파일 오류가 난다
    queryClient.setQueryData(todoQueries.detail(1).queryKey, { name: 'x' })
  13. 단, 제네릭을 직접 적으면 키에 붙은 타입이 무시된다.

  14. getQueryData는 키 타입을 두 번째 타입 인자로 추론해야 태그를 읽는데, 타입 인자를 하나라도 직접 적으면 TypeScript는 나머지를 추론하지 않고 기본값을 쓰기 때문이다.

    // 첫 번째 타입 인자 User를 적었으므로 키 타입은 추론되지 않고 기본값 QueryKey가 된다
    // 태그를 못 읽어서 적은 User | undefined가 그대로 반환 타입이 되고, 오류도 나지 않는다
    const wrong = queryClient.getQueryData<User>(todoQueries.detail(1).queryKey)
  15. 그래서 키에 붙은 타입의 이득은 제네릭을 적지 않을 때만 생긴다.

계층 키와 무효화는 그대로다

  1. invalidateQueries는 exact: true를 주지 않는 기본 설정에서, 필터의 queryKey를 캐시에 있는 각 쿼리 키와 앞에서부터 원소 하나씩 비교하고, 필터 키의 원소가 모두 같으면 캐시 키가 더 길어도 일치로 본다.

  2. 그래서 필터 키 ['todos', 'list']는 ['todos', 'list', { done: false }]와 ['todos', 'list', { done: true }]를 둘 다 무효화하고, ['todos', 'detail', 1]은 건드리지 않는다.

  3. 원소가 객체이면 필터 쪽 객체에 적힌 속성만 비교하므로, 필터 키 ['todos', 'list', { done: true }]는 캐시 키 ['todos', 'list', { done: true, page: 2 }]와도 일치한다.

  4. queryOptions가 돌려준 queryKey도 런타임에는 평범한 배열이어서 이 규칙대로 비교되므로, 기존 키 팩토리의 계층 구조를 queryOptions 패턴에서도 그대로 쓴다.

  5. 대신 팩토리 안에는 키 배열을 돌려주는 함수와 옵션 객체를 돌려주는 함수가 섞인다.

  6. 무효화 범위로만 쓰는 all, lists는 붙일 queryFn이 없으므로 키 배열을 돌려주고, 데이터를 가져오는 list, detail은 옵션 객체를 돌려준다.

  7. 그래서 쓰는 쪽은 함수마다 키를 그대로 넘길지 .queryKey를 꺼내 넘길지 구분해야 하고, 이 구분은 함수 이름에 드러나지 않는다.

  8. 잘못 넘기면 컴파일 오류가 나므로 런타임 버그로 이어지지는 않지만, 고칠 때마다 팩토리 정의를 확인하게 된다.

    export const todoQueries = {
      all: () => ['todos'] as const, // 키 배열
      lists: () => [...todoQueries.all(), 'list'] as const, // 키 배열
      list: (filters: Filters) =>
        queryOptions({ queryKey: [...todoQueries.lists(), filters], queryFn: () => fetchTodos(filters) }), // 옵션 객체
      detail: (id: number) =>
        queryOptions({ queryKey: [...todoQueries.all(), 'detail', id], queryFn: () => fetchTodo(id) }), // 옵션 객체
    }
    
    // 할 일 하나를 고친 뒤, 목록 전체와 그 할 일의 상세를 무효화한다
    function invalidateAfterUpdate(id: number) {
      // lists()는 키 배열이므로 그대로 넘긴다
      queryClient.invalidateQueries({ queryKey: todoQueries.lists() })
      // detail(id)는 옵션 객체이므로 .queryKey를 꺼내 넘긴다
      queryClient.invalidateQueries({ queryKey: todoQueries.detail(id).queryKey })
    }
    
    // 반대로 쓰면 둘 다 컴파일 오류가 난다
    queryClient.invalidateQueries({ queryKey: todoQueries.detail(1) }) // 옵션 객체는 키 배열 자리에 못 들어간다
    queryClient.invalidateQueries({ queryKey: todoQueries.lists().queryKey }) // 키 배열에는 queryKey 속성이 없다

훅이 아니어서 잃는 것

  1. 커스텀 훅은 본문에서 다른 훅을 부를 수 있었다.

  2. 예를 들어 로그인했을 때만 요청해야 하는 데이터라면, useTodo 안에서 로그인 상태를 주는 useUser 훅을 불러 enabled를 정할 수 있었다.

  3. 그러면 로그인 조건은 useTodo 한 곳에만 있고, 컴포넌트는 useTodo(id)만 부르면 된다.

    // before: 로그인 조건이 훅 안에 한 번만 있다
    export function useTodo(id: number) {
      const user = useUser() // ⭐️ 훅 안이라 다른 훅을 부를 수 있다
      return useQuery({
        queryKey: todoKeys.detail(id),
        queryFn: () => fetchTodo(id),
        enabled: user != null, // 로그인 전에는 요청하지 않는다
      })
    }
    
    function TodoDetail({ id }: { id: number }) {
      const { data } = useTodo(id) // 로그인 조건을 몰라도 된다
    }
    function TodoTitle({ id }: { id: number }) {
      const { data } = useTodo(id)
    }
  4. todoQueries.detail은 평범한 함수라서 Rules of Hooks에 따라 안에서 useUser를 부를 수 없다.

useMutation 패턴은 무엇이 달라지나

  1. 기존 방식에서는 useUpdateTodo 같은 커스텀 훅 안에서 useMutation을 부르고, 성공했을 때와 끝났을 때 할 일을 콜백으로 함께 적었다.

    // before: 커스텀 훅
    export function useUpdateTodo() {
      const queryClient = useQueryClient() // 무효화에 쓸 QueryClient를 훅으로 얻는다
      const navigate = useNavigate() // 페이지 이동도 훅으로 얻는다
      return useMutation({
        mutationFn: updateTodo,
        onSuccess: () => navigate('/todos'), // 성공하면 목록으로 이동한다
        // 세 번째 인자 todo는 mutate에 넘긴 값이다: 성공과 실패 모두 무효화한다
        onSettled: (_data, _error, todo) =>
          queryClient.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey }),
      })
    }
  2. 콜백에서 invalidateQueries를 부르려면 QueryClient 인스턴스가 필요해서, 훅 안에서 useQueryClient()로 얻었다.

  3. 뮤테이션에도 queryOptions에 대응하는 mutationOptions 헬퍼가 있고, 구현은 queryOptions처럼 받은 인자를 그대로 돌려준다.

  4. mutationOptions를 부르는 팩토리는 훅이 아니므로 useQueryClient()도 useNavigate()도 부를 수 없다.

    // 안 되는 코드: 팩토리의 콜백 안에서 훅을 부른다
    export const todoMutations = {
      update: () =>
        mutationOptions({
          mutationFn: updateTodo,
          onSettled: (_data, _error, todo) => {
            // 콜백은 렌더링 밖에서 불리므로 여기서 훅을 부르면 런타임 오류가 난다
            // Invalid hook call. Hooks can only be called inside of the body of a function component.
            const queryClient = useQueryClient()
            queryClient.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey })
          },
        }),
    }
  5. 대신 뮤테이션 콜백의 마지막 인자 context에 client(이 뮤테이션을 실행한 QueryClient)가 들어 있어서, 팩토리 안에서도 무효화를 적을 수 있다.

    // after: 팩토리
    export const todoMutations = {
      update: () =>
        mutationOptions({
          mutationFn: updateTodo,
          // 다섯 번째 인자 context.client로 무효화한다: 훅 없이 QueryClient에 닿는다
          onSettled: (_data, _error, todo, _onMutateResult, context) =>
            context.client.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey }),
        }),
    }
  6. navigate처럼 훅에서 얻는 함수는 팩토리 안에서 직접 얻을 수 없으므로, 컴포넌트가 훅으로 얻어 넘겨야 하고, 여기서는 mutate의 두 번째 인자로 넘긴다.

    function EditTodo({ todo }: { todo: Todo }) {
      const navigate = useNavigate() // 컴포넌트는 훅을 부를 수 있다
      const { mutate } = useMutation(todoMutations.update())
      // 이동은 mutate의 두 번째 인자로 넘긴다: 무효화는 팩토리의 onSettled가 맡는다
      return <button onClick={() => mutate(todo, { onSuccess: () => navigate('/todos') })}>저장</button>
    }
  7. 정리하면 커스텀 훅 한 곳에 모여 있던 콜백이, 무효화는 팩토리로, 페이지 이동처럼 화면에 딸린 일은 mutate의 두 번째 인자로 나뉜다.

  8. 나뉘면서 실행 순서도 바뀌어, before는 이동한 뒤에 무효화하지만 after는 팩토리의 onSettled가 돌려준 무효화 Promise가 끝난 뒤에 이동한다.

  9. mutate에 넘긴 콜백은 뮤테이션이 끝나기 전에 컴포넌트가 언마운트되면 불리지 않고, mutate를 연이어 부르면 마지막 호출의 것만 불리므로, 무효화처럼 매번 해야 하는 일은 팩토리에 둔다.

댓글