Skip to content

Commit b74a529

Browse files
authored
Merge pull request #1014 from Shrugsy/add-full-types-to-mutation-callback
2 parents cce75bf + dc9fc32 commit b74a529

File tree

6 files changed

+228
-53
lines changed

6 files changed

+228
-53
lines changed

query-old/docs/api/created-api/hooks.md

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type UseQueryResult<T> = {
6868
isLoading: false; // Query is currently loading for the first time. No data yet.
6969
isFetching: false; // Query is currently fetching, but might have data from an earlier request.
7070
isSuccess: false; // Query has data from a successful load.
71-
isError: false; // Query is currently in "error" state.
71+
isError: false; // Query is currently in an "error" state.
7272

7373
refetch: () => void; // A function to force refetch the query
7474
};
@@ -91,18 +91,50 @@ The query arg is used as a cache key. Changing the query arg will tell the hook
9191
#### Signature
9292

9393
```ts
94-
type MutationHook = () => [
95-
(
96-
arg: any
97-
) => {
98-
unwrap: () => Promise<ResultTypeFrom<D>>;
99-
},
100-
MutationSubState<D> & RequestStatusFlags
101-
];
94+
type UseMutation<Definition> = (
95+
UseMutationStateOptions<Definition>
96+
) => [UseMutationTrigger<Definition>, UseMutationResult<Definition> | SelectedUseMutationResult];
97+
98+
type UseMutationStateOptions<Definition> = {
99+
// A method to determine the contents of `UseMutationResult`
100+
selectFromResult?: (state, defaultMutationStateSelector) => SelectedUseMutationResult extends Record<string, any>
101+
}
102+
103+
type UseMutationTrigger<Definition> = (
104+
arg: ArgTypeFrom<Definition>
105+
) => Promise<{ data: ResultTypeFrom<Definition> } | { error: BaseQueryError | SerializedError }> & {
106+
requestId: string; // A string generated by RTK Query
107+
abort: () => void; // A method to cancel the mutation promise
108+
unwrap: () => Promise<ResultTypeFrom<Definition>>; // A method to unwrap the mutation call and provide the raw response/error
109+
unsubscribe: () => void; // A method to manually unsubscribe from the mutation call
110+
};
111+
112+
type UseMutationResult<Definition> = {
113+
data?: ResultTypeFrom<Definition>; // Returned result if present
114+
endpointName?: string; // The name of the given endpoint for the mutation
115+
error?: any; // Error result if present
116+
fulfilledTimestamp?: number; // Timestamp for when the mutation was completed
117+
isError: boolean; // Mutation is currently in an "error" state
118+
isLoading: boolean; // Mutation has been fired and is awaiting a response
119+
isSuccess: boolean; // Mutation has data from a successful call
120+
isUninitialized: boolean; // Mutation has not been fired yet
121+
originalArgs?: ArgTypeFrom<Definition>; // Arguments passed to the latest mutation call
122+
startedTimeStamp?: number; // Timestamp for when the latest mutation was initiated
123+
};
124+
```
125+
126+
:::tip
127+
128+
The generated `UseMutation` hook will cause a component to re-render by default after the trigger callback is fired as it affects the properties of the result. If you want to call the trigger but don't care about subscribing to the result with the hook, you can use the `selectFromResult` option to limit the properties that the hook cares about.
129+
130+
Passing a completely empty object will prevent the hook from causing a re-render at all, e.g.
131+
```ts
132+
selectFromResult: () => ({})
102133
```
134+
:::
103135
104136
- **Returns**: a tuple containing:
105-
- `trigger`: a function that triggers an update to the data based on the provided argument
137+
- `trigger`: a function that triggers an update to the data based on the provided argument. The trigger function returns a promise with the properties shown above that may be used to handle the behaviour of the promise.
106138
- `mutationState`: a query status object containing the current loading state and metadata about the request
107139
108140
#### Description

src/createAsyncThunk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export type AsyncThunkPayloadCreator<
144144
* A ThunkAction created by `createAsyncThunk`.
145145
* Dispatching it returns a Promise for either a
146146
* fulfilled or rejected action.
147-
* Also, the returned value contains a `abort()` method
147+
* Also, the returned value contains an `abort()` method
148148
* that allows the asyncAction to be cancelled from the outside.
149149
*
150150
* @public

src/query/core/buildInitiate.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import {
1111
AsyncThunk,
1212
ThunkAction,
1313
unwrapResult,
14+
SerializedError,
1415
} from '@reduxjs/toolkit'
1516
import { QuerySubState, SubscriptionOptions } from './apiState'
1617
import { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs'
1718
import { Api } from '../apiTypes'
1819
import { ApiEndpointQuery } from './module'
19-
import { BaseQueryResult } from '../baseQueryTypes'
20+
import { BaseQueryError } from '../baseQueryTypes'
2021

2122
declare module './module' {
2223
export interface ApiEndpointQuery<
@@ -79,18 +80,102 @@ type StartMutationActionCreator<
7980
export type MutationActionCreatorResult<
8081
D extends MutationDefinition<any, any, any, any>
8182
> = Promise<
82-
ReturnType<
83-
BaseQueryResult<
84-
D extends MutationDefinition<any, infer BaseQuery, any, any>
85-
? BaseQuery
86-
: never
87-
>
88-
>
83+
| { data: ResultTypeFrom<D> }
84+
| {
85+
error:
86+
| Exclude<
87+
BaseQueryError<
88+
D extends MutationDefinition<any, infer BaseQuery, any, any>
89+
? BaseQuery
90+
: never
91+
>,
92+
undefined
93+
>
94+
| SerializedError
95+
}
8996
> & {
90-
arg: QueryArgFrom<D>
97+
/** @internal */
98+
arg: {
99+
/**
100+
* The name of the given endpoint for the mutation
101+
*/
102+
endpointName: string
103+
/**
104+
* The original arguments supplied to the mutation call
105+
*/
106+
originalArgs: QueryArgFrom<D>
107+
/**
108+
* Whether the mutation is being tracked in the store.
109+
*/
110+
track?: boolean
111+
/**
112+
* Timestamp for when the mutation was initiated
113+
*/
114+
startedTimeStamp: number
115+
}
116+
/**
117+
* A unique string generated for the request sequence
118+
*/
91119
requestId: string
120+
/**
121+
* A method to cancel the mutation promise. Note that this is not intended to prevent the mutation
122+
* that was fired off from reaching the server, but only to assist in handling the response.
123+
*
124+
* Calling `abort()` prior to the promise resolving will force it to reach the error state with
125+
* the serialized error:
126+
* `{ name: 'AbortError', message: 'Aborted' }`
127+
*
128+
* @example
129+
* ```ts
130+
* const [updateUser] = useUpdateUserMutation();
131+
*
132+
* useEffect(() => {
133+
* const promise = updateUser(id);
134+
* promise
135+
* .unwrap()
136+
* .catch((err) => {
137+
* if (err.name === 'AbortError') return;
138+
* // else handle the unexpected error
139+
* })
140+
*
141+
* return () => {
142+
* promise.abort();
143+
* }
144+
* }, [id, updateUser])
145+
* ```
146+
*/
92147
abort(): void
148+
/**
149+
* Unwraps a mutation call to provide the raw response/error.
150+
*
151+
* @remarks
152+
* If you need to access the error or success payload immediately after a mutation, you can chain .unwrap().
153+
*
154+
* @example
155+
* ```ts
156+
* // codeblock-meta title="Using .unwrap"
157+
* addPost({ id: 1, name: 'Example' })
158+
* .unwrap()
159+
* .then((payload) => console.log('fulfilled', payload))
160+
* .catch((error) => console.error('rejected', error));
161+
* ```
162+
*
163+
* @example
164+
* ```ts
165+
* // codeblock-meta title="Using .unwrap with async await"
166+
* try {
167+
* const payload = await addPost({ id: 1, name: 'Example' }).unwrap();
168+
* console.log('fulfilled', payload)
169+
* } catch (error) {
170+
* console.error('rejected', error);
171+
* }
172+
* ```
173+
*/
93174
unwrap(): Promise<ResultTypeFrom<D>>
175+
/**
176+
* A method to manually unsubscribe from the mutation call, meaning it will be removed from cache after the usual caching grace period.
177+
The value returned by the hook will reset to `isUninitialized` afterwards.
178+
*/
94179
unsubscribe(): void
95180
}
96181

src/query/react/buildHooks.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -321,37 +321,7 @@ export type UseMutation<D extends MutationDefinition<any, any, any, any>> = <
321321
>(
322322
options?: UseMutationStateOptions<D, R>
323323
) => [
324-
(
325-
arg: QueryArgFrom<D>
326-
) => {
327-
/**
328-
* Unwraps a mutation call to provide the raw response/error.
329-
*
330-
* @remarks
331-
* If you need to access the error or success payload immediately after a mutation, you can chain .unwrap().
332-
*
333-
* @example
334-
* ```ts
335-
* // codeblock-meta title="Using .unwrap"
336-
* addPost({ id: 1, name: 'Example' })
337-
* .unwrap()
338-
* .then((payload) => console.log('fulfilled', payload))
339-
* .catch((error) => console.error('rejected', error));
340-
* ```
341-
*
342-
* @example
343-
* ```ts
344-
* // codeblock-meta title="Using .unwrap with async await"
345-
* try {
346-
* const payload = await addPost({ id: 1, name: 'Example' }).unwrap();
347-
* console.log('fulfilled', payload)
348-
* } catch (error) {
349-
* console.error('rejected', error);
350-
* }
351-
* ```
352-
*/
353-
unwrap: () => Promise<ResultTypeFrom<D>>
354-
},
324+
(arg: QueryArgFrom<D>) => MutationActionCreatorResult<D>,
355325
UseMutationStateResult<D, R>
356326
]
357327

src/query/tests/buildHooks.test.tsx

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { rest } from 'msw'
66
import {
77
actionsReducer,
88
expectExactType,
9+
expectType,
910
matchSequence,
1011
setupApiStore,
1112
useRenderCounter,
@@ -14,6 +15,7 @@ import {
1415
import { server } from './mocks/server'
1516
import { AnyAction } from 'redux'
1617
import { SubscriptionOptions } from '../core/apiState'
18+
import { SerializedError } from '../../createAsyncThunk'
1719

1820
// Just setup a temporary in-memory counter for tests that `getIncrementedAmount`.
1921
// This can be used to test how many renders happen due to data changes or
@@ -26,6 +28,16 @@ const api = createApi({
2628
if (arg?.body && 'amount' in arg.body) {
2729
amount += 1
2830
}
31+
32+
if (arg?.body && 'forceError' in arg.body) {
33+
return {
34+
error: {
35+
status: 500,
36+
data: null,
37+
},
38+
}
39+
}
40+
2941
return {
3042
data: arg?.body
3143
? { ...arg.body, ...(amount ? { amount } : {}) }
@@ -44,7 +56,7 @@ const api = createApi({
4456
},
4557
}),
4658
}),
47-
updateUser: build.mutation<any, { name: string }>({
59+
updateUser: build.mutation<{ name: string }, { name: string }>({
4860
query: (update) => ({ body: update }),
4961
}),
5062
getError: build.query({
@@ -804,6 +816,82 @@ describe('hooks tests', () => {
804816
)
805817
)
806818
})
819+
820+
test('useMutation hook callback returns various properties to handle the result', async () => {
821+
function User() {
822+
const [updateUser] = api.endpoints.updateUser.useMutation()
823+
const [successMsg, setSuccessMsg] = React.useState('')
824+
const [errMsg, setErrMsg] = React.useState('')
825+
const [isAborted, setIsAborted] = React.useState(false)
826+
827+
const handleClick = async () => {
828+
const res = updateUser({ name: 'Banana' })
829+
830+
// no-op simply for clearer type assertions
831+
res.then((result) => {
832+
expectExactType<
833+
| {
834+
error: { status: number; data: unknown } | SerializedError
835+
}
836+
| {
837+
data: {
838+
name: string
839+
}
840+
}
841+
>(result)
842+
})
843+
844+
expectType<{
845+
endpointName: string
846+
originalArgs: { name: string }
847+
track?: boolean
848+
startedTimeStamp: number
849+
}>(res.arg)
850+
expectType<string>(res.requestId)
851+
expectType<() => void>(res.abort)
852+
expectType<() => Promise<{ name: string }>>(res.unwrap)
853+
expectType<() => void>(res.unsubscribe)
854+
855+
// abort the mutation immediately to force an error
856+
res.abort()
857+
res
858+
.unwrap()
859+
.then((result) => {
860+
expectType<{ name: string }>(result)
861+
setSuccessMsg(`Successfully updated user ${result.name}`)
862+
})
863+
.catch((err) => {
864+
setErrMsg(
865+
`An error has occurred updating user ${res.arg.originalArgs.name}`
866+
)
867+
if (err.name === 'AbortError') {
868+
setIsAborted(true)
869+
}
870+
})
871+
}
872+
873+
return (
874+
<div>
875+
<button onClick={handleClick}>Update User and abort</button>
876+
<div>{successMsg}</div>
877+
<div>{errMsg}</div>
878+
<div>{isAborted ? 'Request was aborted' : ''}</div>
879+
</div>
880+
)
881+
}
882+
883+
render(<User />, { wrapper: storeRef.wrapper })
884+
expect(screen.queryByText(/An error has occurred/i)).toBeNull()
885+
expect(screen.queryByText(/Successfully updated user/i)).toBeNull()
886+
expect(screen.queryByText('Request was aborted')).toBeNull()
887+
888+
fireEvent.click(
889+
screen.getByRole('button', { name: 'Update User and abort' })
890+
)
891+
await screen.findByText('An error has occurred updating user Banana')
892+
expect(screen.queryByText(/Successfully updated user/i)).toBeNull()
893+
screen.getByText('Request was aborted')
894+
})
807895
})
808896

809897
describe('usePrefetch', () => {

src/query/tests/queryFn.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe('queryFn base implementation tests', () => {
247247
{
248248
const thunk = mutationWithNeither.initiate('mutationWithNeither')
249249
const result = await store.dispatch(thunk)
250-
expect(result.error).toEqual(
250+
expect('error' in result && result.error).toEqual(
251251
expect.objectContaining({
252252
message: 'endpointDefinition.queryFn is not a function',
253253
})

0 commit comments

Comments
 (0)