diff --git a/query-old/docs/api/created-api/hooks.md b/query-old/docs/api/created-api/hooks.md index c7303c18df..d8b4017539 100644 --- a/query-old/docs/api/created-api/hooks.md +++ b/query-old/docs/api/created-api/hooks.md @@ -68,7 +68,7 @@ type UseQueryResult = { 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>; - }, - MutationSubState & RequestStatusFlags -]; +type UseMutation = ( + UseMutationStateOptions +) => [UseMutationTrigger, UseMutationResult | SelectedUseMutationResult]; + +type UseMutationStateOptions = { + // A method to determine the contents of `UseMutationResult` + selectFromResult?: (state, defaultMutationStateSelector) => SelectedUseMutationResult extends Record +} + +type UseMutationTrigger = ( + arg: ArgTypeFrom +) => Promise<{ data: ResultTypeFrom } | { error: BaseQueryError | SerializedError }> & { + requestId: string; // A string generated by RTK Query + abort: () => void; // A method to cancel the mutation promise + unwrap: () => Promise>; // 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 = { + data?: ResultTypeFrom; // 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; // 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. - `mutationState`: a query status object containing the current loading state and metadata about the request #### Description diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts index 8b2b588202..55bc50c04e 100644 --- a/src/createAsyncThunk.ts +++ b/src/createAsyncThunk.ts @@ -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 diff --git a/src/query/core/buildInitiate.ts b/src/query/core/buildInitiate.ts index 8efb99fe95..3a6d7d7b0c 100644 --- a/src/query/core/buildInitiate.ts +++ b/src/query/core/buildInitiate.ts @@ -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< @@ -79,18 +80,102 @@ type StartMutationActionCreator< export type MutationActionCreatorResult< D extends MutationDefinition > = Promise< - ReturnType< - BaseQueryResult< - D extends MutationDefinition - ? BaseQuery - : never - > - > + | { data: ResultTypeFrom } + | { + error: + | Exclude< + BaseQueryError< + D extends MutationDefinition + ? BaseQuery + : never + >, + undefined + > + | SerializedError + } > & { - arg: QueryArgFrom + /** @internal */ + arg: { + /** + * The name of the given endpoint for the mutation + */ + endpointName: string + /** + * The original arguments supplied to the mutation call + */ + originalArgs: QueryArgFrom + /** + * Whether the mutation is being tracked in the store. + */ + track?: boolean + /** + * Timestamp for when the mutation was initiated + */ + startedTimeStamp: number + } + /** + * 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> + /** + * 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 } diff --git a/src/query/react/buildHooks.ts b/src/query/react/buildHooks.ts index 9e9545184b..51c7c34e7a 100644 --- a/src/query/react/buildHooks.ts +++ b/src/query/react/buildHooks.ts @@ -321,37 +321,7 @@ export type UseMutation> = < >( options?: UseMutationStateOptions ) => [ - ( - arg: QueryArgFrom - ) => { - /** - * 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> - }, + (arg: QueryArgFrom) => MutationActionCreatorResult, UseMutationStateResult ] diff --git a/src/query/tests/buildHooks.test.tsx b/src/query/tests/buildHooks.test.tsx index f52a527ff5..5c5cc69a05 100644 --- a/src/query/tests/buildHooks.test.tsx +++ b/src/query/tests/buildHooks.test.tsx @@ -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({ + 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(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 ( +
+ +
{successMsg}
+
{errMsg}
+
{isAborted ? 'Request was aborted' : ''}
+
+ ) + } + + render(, { 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') + expect(screen.queryByText(/Successfully updated user/i)).toBeNull() + screen.getByText('Request was aborted') + }) }) describe('usePrefetch', () => { diff --git a/src/query/tests/queryFn.test.tsx b/src/query/tests/queryFn.test.tsx index a1828e5bb3..1d4a73268f 100644 --- a/src/query/tests/queryFn.test.tsx +++ b/src/query/tests/queryFn.test.tsx @@ -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('error' in result && result.error).toEqual( expect.objectContaining({ message: 'endpointDefinition.queryFn is not a function', })