Skip to content

useMutation invokes the next mutation's onSettled with the previous result #11451

Description

@MinsuPlaid

Describe the bug

When a per-call onSuccess or onError callback starts another mutation using the same useMutation instance, the second mutation's onSettled callback receives the first mutation's result and variables. It runs again with its own result when the second mutation finishes.

The reproduction logs only the callback registered for the second mutation:

[
  { "data": "result-1", "error": null, "variables": 1 },
  { "data": "result-2", "error": null, "variables": 2 }
]

Your minimal, reproducible example

https://yjdm6m.csb.app/ https://codesandbox.io/s/yjdm6m

Steps to reproduce

  1. Open the reproduction and click 1. Start second mutation in onSuccess.
  2. Observe that the second mutation's callback receives both result-1 / variables 1 and result-2 / variables 2.
  3. Click 2. Start second mutation in onError. The second mutation's callback similarly receives the first mutation's error and variables before receiving its own successful result.
  4. As a control, click 3. Sequential calls outside callbacks. This correctly logs only the second mutation's result once.
  5. Repeat with StrictMode enabled using the link at the bottom. The behavior is the same.

Expected behavior

The second mutation's onSettled callback should run only after that mutation settles, with its own data, error, and variables:

[
  { "data": "result-2", "error": null, "variables": 2 }
]

This is not a request to invoke per-call callbacks for every consecutive mutation. The issue is that the latest mutation's callback receives a different mutation's result.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • macOS 26.5
  • Chromium 150.0.0.0, as reported by the browser's user agent
  • React / React DOM 19.2.1
  • Reproduces with StrictMode both enabled and disabled

Tanstack Query adapter

react-query

TanStack Query version

@tanstack/react-query@5.102.8 and @tanstack/query-core@5.102.8

TypeScript version

No response

Additional context

In additional local checks, the second mutation was held pending using a manually resolved Promise. Its onSettled callback still received the first mutation's result before the second mutation finished.

Omitting the second call's optional options argument also produces an internal TypeError, even though the user callback does not throw:

mutation.mutate(1, {
  onSuccess: () => {
    mutation.mutate(2)
  },
})
TypeError: Cannot read properties of undefined (reading 'onSettled')

In MutationObserver.#notify, this.#mutateOptions.onSettled is read after invoking onSuccess or onError. The nested mutate call replaces #mutateOptions, while the action's result and variables still belong to the first mutation.

The pending-Promise and omitted-options cases were checked separately locally; the shared sandbox contains the three button scenarios described above.

Codex assisted with preparing and verifying the reproduction.

Activity

  1. amasen02 commented on Sep 20, 2026

    @amasen02

    This diagnosis is spot-on. The bug is caused by reading this.#mutateOptions dynamically across synchronous callback execution rather than snapshotting a local reference.

    Root Cause Walkthrough

    In packages/query-core/src/mutationObserver.ts inside #notify:

    // Currently in mutationObserver.ts:
    if (this.#mutateOptions) {
      if (action.type === 'success') {
        this.#mutateOptions.onSuccess?.(action.data, action.variables, action.context)
        this.#mutateOptions.onSettled?.(action.data, null, action.variables, action.context)
      } else if (action.type === 'error') {
        this.#mutateOptions.onError?.(action.error, action.variables, action.context)
        this.#mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context)
      }
    }

    When mutation 1 completes:

    1. #notify checks if (this.#mutateOptions) (evaluates to true) and executes this.#mutateOptions.onSuccess(...).
    2. Inside onSuccess, user code calls mutation.mutate(2, mutateOptions2).
    3. MutationObserver.mutate() synchronously re-assigns the instance field:
      this.#mutateOptions = mutateOptions // now mutateOptions2 (or undefined if omitted)
    4. Execution returns from onSuccess back into #notify.
    5. The very next line evaluates this.#mutateOptions.onSettled(...):
      • If the second call passed options, this.#mutateOptions now points to mutateOptions2, so mutateOptions2.onSettled is eagerly called with mutation 1's action.data and action.variables.
      • If the second call omitted options (mutation.mutate(2)), this.#mutateOptions was set to undefined, immediately throwing:
        TypeError: Cannot read properties of undefined (reading 'onSettled')
        

    Fix

    Snapshot this.#mutateOptions to a local variable before executing callbacks so re-entrant calls cannot mutate the active dispatch reference:

    --- a/packages/query-core/src/mutationObserver.ts
    +++ b/packages/query-core/src/mutationObserver.ts
    @@ -240,11 +240,12 @@ export class MutationObserver<
         if (this.#mutateOptions) {
    +      const mutateOptions = this.#mutateOptions
           if (action.type === 'success') {
    -        this.#mutateOptions.onSuccess?.(action.data, action.variables, action.context)
    -        this.#mutateOptions.onSettled?.(action.data, null, action.variables, action.context)
    +        mutateOptions.onSuccess?.(action.data, action.variables, action.context)
    +        mutateOptions.onSettled?.(action.data, null, action.variables, action.context)
           } else if (action.type === 'error') {
    -        this.#mutateOptions.onError?.(action.error, action.variables, action.context)
    -        this.#mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context)
    +        mutateOptions.onError?.(action.error, action.variables, action.context)
    +        mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context)
           }
         }

    Proposed Unit Test

    A test for packages/query-core/src/tests/mutationObserver.test.tsx verifying both re-entrant settling isolation and omitted options:

    test('re-entrant mutate() inside onSuccess does not leak or trigger next onSettled early', async () => {
      const queryClient = createQueryClient()
      const observer = new MutationObserver(queryClient, {
        mutationFn: async (val: number) => `result-${val}`,
      })
    
      const calls: Array<{ stage: string; data?: string; val: number }> = []
    
      observer.mutate(1, {
        onSuccess: () => {
          observer.mutate(2, {
            onSettled: (data) => {
              calls.push({ stage: 'second-settled', data: data as string, val: 2 })
            },
          })
        },
        onSettled: (data) => {
          calls.push({ stage: 'first-settled', data: data as string, val: 1 })
        },
      })
    
      await waitFor(() => expect(calls).toHaveLength(2))
    
      expect(calls).toEqual([
        { stage: 'first-settled', data: 'result-1', val: 1 },
        { stage: 'second-settled', data: 'result-2', val: 2 },
      ])
    })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions