-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add full types to useMutation's returned callback #1014
Changes from 10 commits
a42105b
36ecc80
2bb34a9
3b56f57
b377eb3
1a44f22
d7e83f0
07a430b
b47b6fc
66dd9bb
dc9fc32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -68,7 +68,7 @@ type UseQueryResult<T> = { | |
isLoading: false; // Query is currently loading for the first time. No data yet. | ||
isFetching: false; // Query is currently fetching, but might have data from an earlier request. | ||
isSuccess: false; // Query has data from a successful load. | ||
isError: false; // Query is currently in "error" state. | ||
isError: false; // Query is currently in an "error" state. | ||
|
||
refetch: () => void; // A function to force refetch the query | ||
}; | ||
|
@@ -91,18 +91,50 @@ The query arg is used as a cache key. Changing the query arg will tell the hook | |
#### Signature | ||
|
||
```ts | ||
type MutationHook = () => [ | ||
( | ||
arg: any | ||
) => { | ||
unwrap: () => Promise<ResultTypeFrom<D>>; | ||
}, | ||
MutationSubState<D> & RequestStatusFlags | ||
]; | ||
type UseMutation<Definition> = ( | ||
UseMutationStateOptions<Definition> | ||
) => [UseMutationTrigger<Definition>, UseMutationResult<Definition> | SelectedUseMutationResult]; | ||
|
||
type UseMutationStateOptions<Definition> = { | ||
// A method to determine the contents of `UseMutationResult` | ||
selectFromResult?: (state, defaultMutationStateSelector) => SelectedUseMutationResult extends Record<string, any> | ||
} | ||
|
||
type UseMutationTrigger<Definition> = ( | ||
arg: ArgTypeFrom<Definition> | ||
) => Promise<{ data: ResultTypeFrom<Definition> } | { error: BaseQueryError | SerializedError }> & { | ||
requestId: string; // A string generated by RTK Query | ||
abort: () => void; // A method to cancel the mutation promise | ||
unwrap: () => Promise<ResultTypeFrom<Definition>>; // A method to unwrap the mutation call and provide the raw response/error | ||
unsubscribe: () => void; // A method to manually unsubscribe from the mutation call | ||
}; | ||
|
||
type UseMutationResult<Definition> = { | ||
data?: ResultTypeFrom<Definition>; // Returned result if present | ||
endpointName?: string; // The name of the given endpoint for the mutation | ||
error?: any; // Error result if present | ||
fulfilledTimestamp?: number; // Timestamp for when the mutation was completed | ||
isError: boolean; // Mutation is currently in an "error" state | ||
isLoading: boolean; // Mutation has been fired and is awaiting a response | ||
isSuccess: boolean; // Mutation has data from a successful call | ||
isUninitialized: boolean; // Mutation has not been fired yet | ||
originalArgs?: ArgTypeFrom<Definition>; // Arguments passed to the latest mutation call | ||
startedTimeStamp?: number; // Timestamp for when the latest mutation was initiated | ||
}; | ||
``` | ||
|
||
:::tip | ||
|
||
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. | ||
|
||
Passing a completely empty object will prevent the hook from causing a re-render at all, e.g. | ||
```ts | ||
selectFromResult: () => ({}) | ||
``` | ||
::: | ||
|
||
- **Returns**: a tuple containing: | ||
- `trigger`: a function that triggers an update to the data based on the provided argument | ||
- `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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your non-American behavior here is silly! 🤣 |
||
- `mutationState`: a query status object containing the current loading state and metadata about the request | ||
|
||
#### Description | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ import { rest } from 'msw' | |
import { | ||
actionsReducer, | ||
expectExactType, | ||
expectType, | ||
matchSequence, | ||
setupApiStore, | ||
useRenderCounter, | ||
|
@@ -14,6 +15,7 @@ import { | |
import { server } from './mocks/server' | ||
import { AnyAction } from 'redux' | ||
import { SubscriptionOptions } from '../core/apiState' | ||
import { SerializedError } from '../../createAsyncThunk' | ||
|
||
// Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. | ||
// This can be used to test how many renders happen due to data changes or | ||
|
@@ -26,6 +28,16 @@ const api = createApi({ | |
if (arg?.body && 'amount' in arg.body) { | ||
amount += 1 | ||
} | ||
|
||
if (arg?.body && 'forceError' in arg.body) { | ||
return { | ||
error: { | ||
status: 500, | ||
data: null, | ||
}, | ||
} | ||
} | ||
|
||
return { | ||
data: arg?.body | ||
? { ...arg.body, ...(amount ? { amount } : {}) } | ||
|
@@ -44,7 +56,7 @@ const api = createApi({ | |
}, | ||
}), | ||
}), | ||
updateUser: build.mutation<any, { name: string }>({ | ||
updateUser: build.mutation<{ name: string }, { name: string }>({ | ||
query: (update) => ({ body: update }), | ||
}), | ||
getError: build.query({ | ||
|
@@ -804,6 +816,82 @@ describe('hooks tests', () => { | |
) | ||
) | ||
}) | ||
|
||
test('useMutation hook callback returns various properties to handle the result', async () => { | ||
function User() { | ||
const [updateUser] = api.endpoints.updateUser.useMutation() | ||
const [successMsg, setSuccessMsg] = React.useState('') | ||
const [errMsg, setErrMsg] = React.useState('') | ||
const [isAborted, setIsAborted] = React.useState(false) | ||
|
||
const handleClick = async () => { | ||
const res = updateUser({ name: 'Banana' }) | ||
|
||
// no-op simply for clearer type assertions | ||
res.then((result) => { | ||
expectExactType< | ||
| { | ||
error: { status: number; data: unknown } | SerializedError | ||
} | ||
| { | ||
data: { | ||
name: string | ||
} | ||
} | ||
>(result) | ||
}) | ||
|
||
expectType<{ | ||
endpointName: string | ||
originalArgs: { name: string } | ||
track?: boolean | ||
startedTimeStamp: number | ||
}>(res.arg) | ||
expectType<string>(res.requestId) | ||
expectType<() => void>(res.abort) | ||
expectType<() => Promise<{ name: string }>>(res.unwrap) | ||
expectType<() => void>(res.unsubscribe) | ||
|
||
// abort the mutation immediately to force an error | ||
res.abort() | ||
res | ||
.unwrap() | ||
.then((result) => { | ||
expectType<{ name: string }>(result) | ||
setSuccessMsg(`Successfully updated user ${result.name}`) | ||
}) | ||
.catch((err) => { | ||
setErrMsg( | ||
`An error has occurred updating user ${res.arg.originalArgs.name}` | ||
) | ||
if (err.name === 'AbortError') { | ||
setIsAborted(true) | ||
} | ||
}) | ||
} | ||
|
||
return ( | ||
<div> | ||
<button onClick={handleClick}>Update User and abort</button> | ||
<div>{successMsg}</div> | ||
<div>{errMsg}</div> | ||
<div>{isAborted ? 'Request was aborted' : ''}</div> | ||
</div> | ||
) | ||
} | ||
|
||
render(<User />, { wrapper: storeRef.wrapper }) | ||
expect(screen.queryByText(/An error has occurred/i)).toBeNull() | ||
expect(screen.queryByText(/Successfully updated user/i)).toBeNull() | ||
expect(screen.queryByText('Request was aborted')).toBeNull() | ||
|
||
fireEvent.click( | ||
screen.getByRole('button', { name: 'Update User and abort' }) | ||
) | ||
await screen.findByText('An error has occurred updating user Banana') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Appreciate you keeping the 🍌 s around 💯 |
||
expect(screen.queryByText(/Successfully updated user/i)).toBeNull() | ||
screen.getByText('Request was aborted') | ||
}) | ||
}) | ||
|
||
describe('usePrefetch', () => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -247,7 +247,7 @@ describe('queryFn base implementation tests', () => { | |
{ | ||
const thunk = mutationWithNeither.initiate('mutationWithNeither') | ||
const result = await store.dispatch(thunk) | ||
expect(result.error).toEqual( | ||
expect((result as any).error).toEqual( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI this change is because { data: string; } | { error: string | SerializedError } If anyone has a better idea for handling this than There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decided on a better option than expect('error' in result && result.error).toEqual( |
||
expect.objectContaining({ | ||
message: 'endpointDefinition.queryFn is not a function', | ||
}) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even giving
selectFromResult
, starting a mutation will trigger at least one re-render.