Skip to content
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

52 changes: 42 additions & 10 deletions query-old/docs/api/created-api/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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.
Copy link
Member

@phryneas phryneas Apr 24, 2021

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.

```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.
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Down
2 changes: 1 addition & 1 deletion src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export type AsyncThunkPayloadCreator<
* A ThunkAction created by `createAsyncThunk`.
* Dispatching it returns a Promise for either a
* fulfilled or rejected action.
* Also, the returned value contains a `abort()` method
* Also, the returned value contains an `abort()` method
* that allows the asyncAction to be cancelled from the outside.
*
* @public
Expand Down
103 changes: 94 additions & 9 deletions src/query/core/buildInitiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
AsyncThunk,
ThunkAction,
unwrapResult,
SerializedError,
} from '@reduxjs/toolkit'
import { QuerySubState, SubscriptionOptions } from './apiState'
import { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs'
import { Api } from '../apiTypes'
import { ApiEndpointQuery } from './module'
import { BaseQueryResult } from '../baseQueryTypes'
import { BaseQueryError } from '../baseQueryTypes'

declare module './module' {
export interface ApiEndpointQuery<
Expand Down Expand Up @@ -79,18 +80,102 @@ type StartMutationActionCreator<
export type MutationActionCreatorResult<
D extends MutationDefinition<any, any, any, any>
> = Promise<
ReturnType<
BaseQueryResult<
D extends MutationDefinition<any, infer BaseQuery, any, any>
? BaseQuery
: never
>
>
| { data: ResultTypeFrom<D> }
| {
error:
| Exclude<
BaseQueryError<
D extends MutationDefinition<any, infer BaseQuery, any, any>
? BaseQuery
: never
>,
undefined
>
| SerializedError
}
> & {
arg: QueryArgFrom<D>
/** @internal */
arg: {
Shrugsy marked this conversation as resolved.
Show resolved Hide resolved
/**
* The name of the given endpoint for the mutation
*/
endpointName: string
/**
* The original arguments supplied to the mutation call
*/
originalArgs: QueryArgFrom<D>
/**
* Whether the mutation is being tracked in the store.
*/
track?: boolean
/**
* Timestamp for when the mutation was initiated
*/
startedTimeStamp: number
}
phryneas marked this conversation as resolved.
Show resolved Hide resolved
/**
* A unique string generated for the request sequence
*/
requestId: string
/**
* A method to cancel the mutation promise. Note that this is not intended to prevent the mutation
* that was fired off from reaching the server, but only to assist in handling the response.
*
* Calling `abort()` prior to the promise resolving will force it to reach the error state with
* the serialized error:
* `{ name: 'AbortError', message: 'Aborted' }`
*
* @example
* ```ts
* const [updateUser] = useUpdateUserMutation();
*
* useEffect(() => {
* const promise = updateUser(id);
* promise
* .unwrap()
* .catch((err) => {
* if (err.name === 'AbortError') return;
* // else handle the unexpected error
* })
*
* return () => {
* promise.abort();
* }
* }, [id, updateUser])
* ```
*/
abort(): void
/**
* Unwraps a mutation call to provide the raw response/error.
*
* @remarks
* If you need to access the error or success payload immediately after a mutation, you can chain .unwrap().
*
* @example
* ```ts
* // codeblock-meta title="Using .unwrap"
* addPost({ id: 1, name: 'Example' })
* .unwrap()
* .then((payload) => console.log('fulfilled', payload))
* .catch((error) => console.error('rejected', error));
* ```
*
* @example
* ```ts
* // codeblock-meta title="Using .unwrap with async await"
* try {
* const payload = await addPost({ id: 1, name: 'Example' }).unwrap();
* console.log('fulfilled', payload)
* } catch (error) {
* console.error('rejected', error);
* }
* ```
*/
unwrap(): Promise<ResultTypeFrom<D>>
/**
* A method to manually unsubscribe from the mutation call, meaning it will be removed from cache after the usual caching grace period.
The value returned by the hook will reset to `isUninitialized` afterwards.
*/
unsubscribe(): void
}

Expand Down
32 changes: 1 addition & 31 deletions src/query/react/buildHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,37 +321,7 @@ export type UseMutation<D extends MutationDefinition<any, any, any, any>> = <
>(
options?: UseMutationStateOptions<D, R>
) => [
(
arg: QueryArgFrom<D>
) => {
/**
* Unwraps a mutation call to provide the raw response/error.
*
* @remarks
* If you need to access the error or success payload immediately after a mutation, you can chain .unwrap().
*
* @example
* ```ts
* // codeblock-meta title="Using .unwrap"
* addPost({ id: 1, name: 'Example' })
* .unwrap()
* .then((payload) => console.log('fulfilled', payload))
* .catch((error) => console.error('rejected', error));
* ```
*
* @example
* ```ts
* // codeblock-meta title="Using .unwrap with async await"
* try {
* const payload = await addPost({ id: 1, name: 'Example' }).unwrap();
* console.log('fulfilled', payload)
* } catch (error) {
* console.error('rejected', error);
* }
* ```
*/
unwrap: () => Promise<ResultTypeFrom<D>>
},
(arg: QueryArgFrom<D>) => MutationActionCreatorResult<D>,
UseMutationStateResult<D, R>
]

Expand Down
90 changes: 89 additions & 1 deletion src/query/tests/buildHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { rest } from 'msw'
import {
actionsReducer,
expectExactType,
expectType,
matchSequence,
setupApiStore,
useRenderCounter,
Expand All @@ -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
Expand All @@ -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 } : {}) }
Expand All @@ -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({
Expand Down Expand Up @@ -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')
Copy link
Member

Choose a reason for hiding this comment

The 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', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/query/tests/queryFn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI this change is because result previously got inferred as any, but is now more accurately:

{ data: string; } | { error: string | SerializedError }

If anyone has a better idea for handling this than any-casting I'm all ears

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided on a better option than as any:

      expect('error' in result && result.error).toEqual(

expect.objectContaining({
message: 'endpointDefinition.queryFn is not a function',
})
Expand Down