태그 보관물: react

[react-query] 리렌더링 시 useMutation 의 동작과 주의사항

useMutation 은 useEffect, useCallback 같은 hook 과는 다르게 의존성을 설정할 수 없다. 그럼, 리렌더링 시에 useMutation에서 반환되는 mutate() 는 항상 새로 만들어 지는 걸까?

useMutation 에 파라메터로 넘기는 mutaionFn, onMutate, onSettled, onSuccess 등이 리렌더링 시 변경될 경우, mutate() 를 호출 하면 각 상황에서 변경된 함수가 호출되는 것으로 봐서 그럴 수도 있어 보인다.

그렇다면 mutate() 를 호출 해서 mutationFn 이 이미 실행 중인 상황에서 리렌더링이 일어나 onSuccess 등이 변경 되고, 그 후 mutationFn 의 실행이 완료된다면 어떻게 될까? mutationFn 이 성공적으로 수행된 후 호출 되는 onSuccess 는 앞서 mutate() 가 호출 되었을 당시의 onSuccess 함수가 아니라 리렌더링 중 변경된 onSuccess 함수가 호출된다.

실제 react-query 가 어떻게 구현되어 있는지 살펴보자.

  const [observer] = React.useState(
    () =>
      new MutationObserver<TData, TError, TVariables, TContext>(
        queryClient,
        options,
      ),
  )

useMutation 의 구현을 보면 observer 라는 state 를 만들어 쓰는 것을 볼 수 있다.

  React.useEffect(() => {
    observer.setOptions(options)
  }, [observer, options])

useMutation 에 파라메터로 넘어가는 옵션이 변경될 경우, 이 observer state 의 setOptions() 를 호출 하여 변경해 주게되며,

  const mutate = React.useCallback<
    UseMutateFunction<TData, TError, TVariables, TContext>
  >(
    (variables, mutateOptions) => {
      observer.mutate(variables, mutateOptions).catch(noop)
    },
    [observer],
  )

우리가 useMutation() 의 결과로 반환받는 mutate 는 useCallback() 을 사용하여 만드는데, 의존성 목록에는 observer 만 들어가 있어 mutate 는 새로 만들어지지 않는다.

좀 더 안 쪽을 살펴 보자. observer 의 mutate() 를 호출 했을때, 즉, 우리가 useMutation()에서 반환 받은 mutate() 함수를 호출 했을 때 무슨일이 일어나는걸까?

  mutate(
    variables?: TVariables,
    options?: MutateOptions<TData, TError, TVariables, TContext>,
  ): Promise<TData> {
    this.mutateOptions = options

    if (this.currentMutation) {
      this.currentMutation.removeObserver(this)
    }

    this.currentMutation = this.client.getMutationCache().build(this.client, {
      ...this.options,
      variables:
        typeof variables !== 'undefined' ? variables : this.options.variables,
    })

    this.currentMutation.addObserver(this)

    return this.currentMutation.execute()
  }

mutate() 가 호출 되는 시점에 현재의 옵션을 넘겨서 Mutation 객체를 새로 만들고, 마지막에 Mutation.execute() 함수를 호출해 준다.

async execute(): Promise<TData> {
    const executeMutation = () => {
      this.retryer = createRetryer({
        fn: () => {
          if (!this.options.mutationFn) {
            return Promise.reject('No mutationFn found')
          }
          return this.options.mutationFn(this.state.variables!)
        },
...
      })

      return this.retryer.promise
    }

...
      const data = await executeMutation()

      // Notify cache callback
      await this.mutationCache.config.onSuccess?.(
        data,
        this.state.variables,
        this.state.context,
        this as Mutation<unknown, unknown, unknown, unknown>,
      )
...
  }

Mutation.execute() 는 생성 시 넘겨 받았던 options의 mutationFn 을 호출 하고, 그 결과에 따라 onSuccess 등을 호출 해 준다.

여기까지 봤을때는, mutate() 호출 시 객체를 생성하고, 필요한 정보는 해당 객체가 다 가지고 있는 형태이므로 이후, useMutation() 이 새로 호출되고 옵션이 바뀌더라도, 현재 실행중인 mutate 에는 영향을 미치지 않을 것으로 보인다.

그런데 아직 보지 못한 곳이 있다. observer의 setOptons().

  setOptions(
    options?: MutationObserverOptions<TData, TError, TVariables, TContext>,
  ) {
    const prevOptions = this.options
    this.options = this.client.defaultMutationOptions(options)
    if (!shallowEqualObjects(prevOptions, this.options)) {
      this.client.getMutationCache().notify({
        type: 'observerOptionsUpdated',
        mutation: this.currentMutation,
        observer: this,
      })
    }
    this.currentMutation?.setOptions(this.options)
  }

뭔가 이유가 있겠지만, 개인적으로는 좀 의아한데… 마지막에 현재 mutation, 즉, 마지막으로 mutate() 호출될 당시의 mutation 객체의 옵션을 같이 바꿔준다.

이로인해, mutaionFn 이 실행 중 useMutation의 옵션이 바뀌게 되면, mutationFn 이 끝난 후 처리는 바뀐 옵션대로 하게되는 것이다.

여기서 useMutation() 사용할때 한 가지 주의 사항이 생긴다. mutate()를 호출한 후 useMutation()의 옵션이 변하는 경우에 대해 고려하라는 것.

의도한게 아니라면, mutate() 호출이 완료되기 전에 useMutation() 의 옵션이 변경된는 상황은 막아야 한다. 보통 react-query 를 사용하는 목적을 생각해보면, mutationFn 을 API 호출 작업일 것이고, 해당 작업은 서버나 네트워크 상황 등에 따라 매우 느리게 끝날 가능성이 있다. 그리고, 그 사이 useMutation()의 옵션에 영향을 주는 state 가 변경되어 리렌더링이 일어나면, mutationFn 이 완료되었을 때는 의도치 않은 작업이 수행될 수 있다.