Skip to content

Commit 6b2ecdf

Browse files
committed
fix(#4247): use useTransition to track mutation isMutating
1 parent 46f3954 commit 6b2ecdf

2 files changed

Lines changed: 68 additions & 21 deletions

File tree

src/mutation/index.ts

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
TriggerWithoutArgs,
2020
TriggerWithOptionsArgs
2121
} from './types'
22+
import { useTransition } from './use-transition'
2223

2324
const mutation = (<Data, Error>() =>
2425
(
@@ -36,13 +37,28 @@ const mutation = (<Data, Error>() =>
3637
const [stateRef, stateDependencies, setState] = useStateWithDeps<{
3738
data: Data | undefined
3839
error: Error | undefined
39-
isMutating: boolean
4040
}>({
4141
data: UNDEFINED,
42-
error: UNDEFINED,
43-
isMutating: false
42+
error: UNDEFINED
4443
})
4544

45+
// https://github.com/vercel/swr/issues/4247
46+
//
47+
// In short, when `trigger` is called with in a transition (e.g. React's <form action /> action prop),
48+
// any state update inside the transtion (a.k.a. the `trigger` function) will be deferred/delayed until
49+
// the transition finishes, which means async function resolved/rejected.
50+
//
51+
// However, we don't want `isMutating` update (false -> true -> false) to be deferred/delayed, otherwise
52+
// the UI won't be able to reflect the loading state.
53+
//
54+
// One way to do this is to use `useTransition`. In React 19, `useTransition`'s `isPending` can be used to
55+
// track async transition resolved/rejected state. And `isPending` would be an urgent update that won't be
56+
// deferred/delayed.
57+
//
58+
// React 18's `useTransition` doesn't support async function tracking. In React 16 and 17, there is no
59+
// `useTransition` at all. A polyfill will be used.
60+
const [isMutating, startMutation] = useTransition()
61+
4662
const currentState = stateRef.current
4763

4864
const trigger = useCallback(
@@ -71,31 +87,37 @@ const mutation = (<Data, Error>() =>
7187

7288
ditchMutationsUntilRef.current = mutationStartedAt
7389

74-
setState({ isMutating: true })
90+
const mutatePromise = mutate<Data>(
91+
serializedKey,
92+
(fetcherRef.current as any)(resolvedKey, { arg }),
93+
// We must throw the error here so we can catch and update the states.
94+
mergeObjects(options, { throwOnError: true })
95+
)
96+
97+
// startTransition returns void, so we can only use it to track the async function state
98+
startMutation(async () => {
99+
try {
100+
await mutatePromise
101+
} catch {
102+
// ignore error in transition state tracking
103+
}
104+
})
75105

76106
try {
77-
const data = await mutate<Data>(
78-
serializedKey,
79-
(fetcherRef.current as any)(resolvedKey, { arg }),
80-
// We must throw the error here so we can catch and update the states.
81-
mergeObjects(options, { throwOnError: true })
82-
)
107+
// actually get result from the mutation promise and handle potential error
108+
const data = await mutatePromise
83109

84110
// If it's reset after the mutation, we don't broadcast any state change.
85111
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
86-
startTransition(() =>
87-
setState({ data, isMutating: false, error: undefined })
88-
)
112+
startTransition(() => setState({ data, error: undefined }))
89113
options.onSuccess?.(data as Data, serializedKey, options)
90114
}
91115
return data
92116
} catch (error) {
93117
// If it's reset after the mutation, we don't broadcast any state change
94118
// or throw because it's discarded.
95119
if (ditchMutationsUntilRef.current <= mutationStartedAt) {
96-
startTransition(() =>
97-
setState({ error: error as Error, isMutating: false })
98-
)
120+
startTransition(() => setState({ error: error as Error }))
99121
options.onError?.(error as Error, serializedKey, options)
100122
if (options.throwOnError) {
101123
throw error as Error
@@ -109,7 +131,7 @@ const mutation = (<Data, Error>() =>
109131

110132
const reset = useCallback(() => {
111133
ditchMutationsUntilRef.current = getTimestamp()
112-
setState({ data: UNDEFINED, error: UNDEFINED, isMutating: false })
134+
setState({ data: UNDEFINED, error: UNDEFINED })
113135
// eslint-disable-next-line react-hooks/exhaustive-deps
114136
}, [])
115137

@@ -133,10 +155,7 @@ const mutation = (<Data, Error>() =>
133155
stateDependencies.error = true
134156
return currentState.error
135157
},
136-
get isMutating() {
137-
stateDependencies.isMutating = true
138-
return currentState.isMutating
139-
}
158+
isMutating
140159
}
141160
}) as unknown as Middleware
142161

src/mutation/use-transition.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { TransitionFunction, TransitionStartFunction } from 'react'
2+
import React, { useCallback, useState } from 'react'
3+
import { IS_REACT_LEGACY, isFunction, isPromiseLike } from '../_internal'
4+
5+
type UseTransition = () => [boolean, TransitionStartFunction]
6+
7+
// React 16–18: no useTransition, or useTransition w/o async function tracking support.
8+
// Track async pending manually with useState.
9+
const useTransitionLegacy: UseTransition = () => {
10+
const [isPending, setIsPending] = useState(false)
11+
const start = useCallback((cb: TransitionFunction) => {
12+
const result = cb()
13+
if (isPromiseLike(result)) {
14+
setIsPending(true)
15+
result.finally(() => setIsPending(false))
16+
}
17+
}, [])
18+
return [isPending, start]
19+
}
20+
21+
// React 18 introduced useTransition, but it can only handle sync transitions.
22+
// React 19 introduced async support in useTransition natively.
23+
// We can detect it via React.use which was also added in React 19.
24+
const IS_REACT_19 = !IS_REACT_LEGACY && isFunction((React as any).use)
25+
26+
export const useTransition: UseTransition = IS_REACT_19
27+
? React.useTransition
28+
: useTransitionLegacy

0 commit comments

Comments
 (0)