[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 가 어떻게 구현되어 있는지 살펴보자.

query/packages/react-query/src /useMutation.ts
const [observer] = React.useState( () => new MutationObserver<TData, TError, TVariables, TContext>( queryClient, options, ), )

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

query/packages/react-query/src /useMutation.ts
React.useEffect(() => { observer.setOptions(options) }, [observer, options])

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

query/packages/react-query/src /useMutation.ts
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() 함수를 호출 했을 때 무슨일이 일어나는걸까?

query/packages/query-core/src /mutationObserver.ts
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() 함수를 호출해 준다.

query/packages/query-core/src /mutation.ts
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().

query/packages/query-core/src /mutationObserver.ts
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 이 완료되었을 때는 의도치 않은 작업이 수행될 수 있다.