@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
505 lines (489 loc) • 15.1 kB
text/typescript
import type {
BaseQueryError,
BaseQueryFn,
BaseQueryMeta,
} from '../../baseQueryTypes'
import { DefinitionType, isAnyQueryDefinition } from '../../endpointDefinitions'
import type { Recipe } from '../buildThunks'
import { isFulfilled, isPending, isRejected } from '../rtkImports'
import type {
MutationBaseLifecycleApi,
QueryBaseLifecycleApi,
} from './cacheLifecycle'
import type {
ApiMiddlewareInternalHandler,
InternalHandlerBuilder,
PromiseConstructorWithKnownReason,
PromiseWithKnownReason,
} from './types'
export type ReferenceQueryLifecycle = never
type QueryLifecyclePromises<ResultType, BaseQuery extends BaseQueryFn> = {
/**
* Promise that will resolve with the (transformed) query result.
*
* If the query fails, this promise will reject with the error.
*
* This allows you to `await` for the query to finish.
*
* If you don't interact with this promise, it will not throw.
*/
queryFulfilled: PromiseWithKnownReason<
{
/**
* The (transformed) query result.
*/
data: ResultType
/**
* The `meta` returned by the `baseQuery`
*/
meta: BaseQueryMeta<BaseQuery>
},
QueryFulfilledRejectionReason<BaseQuery>
>
}
type QueryFulfilledRejectionReason<BaseQuery extends BaseQueryFn> =
| {
error: BaseQueryError<BaseQuery>
/**
* If this is `false`, that means this error was returned from the `baseQuery` or `queryFn` in a controlled manner.
*/
isUnhandledError: false
/**
* The `meta` returned by the `baseQuery`
*/
meta: BaseQueryMeta<BaseQuery>
}
| {
error: unknown
meta?: undefined
/**
* If this is `true`, that means that this error is the result of `baseQueryFn`, `queryFn`, `transformResponse` or `transformErrorResponse` throwing an error instead of handling it properly.
* There can not be made any assumption about the shape of `error`.
*/
isUnhandledError: true
}
export type QueryLifecycleQueryExtraOptions<
ResultType,
QueryArg,
BaseQuery extends BaseQueryFn,
ReducerPath extends string = string,
> = {
/**
* A function that is called when the individual query is started. The function is called with a lifecycle api object containing properties such as `queryFulfilled`, allowing code to be run when a query is started, when it succeeds, and when it fails (i.e. throughout the lifecycle of an individual query/mutation call).
*
* Can be used to perform side-effects throughout the lifecycle of the query.
*
* @example
* ```ts
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
* import { messageCreated } from './notificationsSlice
* export interface Post {
* id: number
* name: string
* }
*
* const api = createApi({
* baseQuery: fetchBaseQuery({
* baseUrl: '/',
* }),
* endpoints: (build) => ({
* getPost: build.query<Post, number>({
* query: (id) => `post/${id}`,
* async onQueryStarted(id, { dispatch, queryFulfilled }) {
* // `onStart` side-effect
* dispatch(messageCreated('Fetching posts...'))
* try {
* const { data } = await queryFulfilled
* // `onSuccess` side-effect
* dispatch(messageCreated('Posts received!'))
* } catch (err) {
* // `onError` side-effect
* dispatch(messageCreated('Error fetching posts!'))
* }
* }
* }),
* }),
* })
* ```
*/
onQueryStarted?(
queryArgument: QueryArg,
queryLifeCycleApi: QueryLifecycleApi<
QueryArg,
BaseQuery,
ResultType,
ReducerPath
>,
): Promise<void> | void
}
export type QueryLifecycleInfiniteQueryExtraOptions<
ResultType,
QueryArg,
BaseQuery extends BaseQueryFn,
ReducerPath extends string = string,
> = QueryLifecycleQueryExtraOptions<
ResultType,
QueryArg,
BaseQuery,
ReducerPath
>
export type QueryLifecycleMutationExtraOptions<
ResultType,
QueryArg,
BaseQuery extends BaseQueryFn,
ReducerPath extends string = string,
> = {
/**
* A function that is called when the individual mutation is started. The function is called with a lifecycle api object containing properties such as `queryFulfilled`, allowing code to be run when a query is started, when it succeeds, and when it fails (i.e. throughout the lifecycle of an individual query/mutation call).
*
* Can be used for `optimistic updates`.
*
* @example
*
* ```ts
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
* export interface Post {
* id: number
* name: string
* }
*
* const api = createApi({
* baseQuery: fetchBaseQuery({
* baseUrl: '/',
* }),
* tagTypes: ['Post'],
* endpoints: (build) => ({
* getPost: build.query<Post, number>({
* query: (id) => `post/${id}`,
* providesTags: ['Post'],
* }),
* updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
* query: ({ id, ...patch }) => ({
* url: `post/${id}`,
* method: 'PATCH',
* body: patch,
* }),
* invalidatesTags: ['Post'],
* async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
* const patchResult = dispatch(
* api.util.updateQueryData('getPost', id, (draft) => {
* Object.assign(draft, patch)
* })
* )
* try {
* await queryFulfilled
* } catch {
* patchResult.undo()
* }
* },
* }),
* }),
* })
* ```
*/
onQueryStarted?(
queryArgument: QueryArg,
mutationLifeCycleApi: MutationLifecycleApi<
QueryArg,
BaseQuery,
ResultType,
ReducerPath
>,
): Promise<void> | void
}
export interface QueryLifecycleApi<
QueryArg,
BaseQuery extends BaseQueryFn,
ResultType,
ReducerPath extends string = string,
> extends QueryBaseLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>,
QueryLifecyclePromises<ResultType, BaseQuery> {}
export type MutationLifecycleApi<
QueryArg,
BaseQuery extends BaseQueryFn,
ResultType,
ReducerPath extends string = string,
> = MutationBaseLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath> &
QueryLifecyclePromises<ResultType, BaseQuery>
/**
* Provides a way to define a strongly-typed version of
* {@linkcode QueryLifecycleQueryExtraOptions.onQueryStarted | onQueryStarted}
* for a specific query.
*
* @example
* <caption>#### __Create and reuse a strongly-typed `onQueryStarted` function__</caption>
*
* ```ts
* import type { TypedQueryOnQueryStarted } from '@reduxjs/toolkit/query'
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
*
* type Post = {
* id: number
* title: string
* userId: number
* }
*
* type PostsApiResponse = {
* posts: Post[]
* total: number
* skip: number
* limit: number
* }
*
* type QueryArgument = number | undefined
*
* type BaseQueryFunction = ReturnType<typeof fetchBaseQuery>
*
* const baseApiSlice = createApi({
* baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }),
* reducerPath: 'postsApi',
* tagTypes: ['Posts'],
* endpoints: (build) => ({
* getPosts: build.query<PostsApiResponse, void>({
* query: () => `/posts`,
* }),
*
* getPostById: build.query<Post, QueryArgument>({
* query: (postId) => `/posts/${postId}`,
* }),
* }),
* })
*
* const updatePostOnFulfilled: TypedQueryOnQueryStarted<
* PostsApiResponse,
* QueryArgument,
* BaseQueryFunction,
* 'postsApi'
* > = async (queryArgument, { dispatch, queryFulfilled }) => {
* const result = await queryFulfilled
*
* const { posts } = result.data
*
* // Pre-fill the individual post entries with the results
* // from the list endpoint query
* dispatch(
* baseApiSlice.util.upsertQueryEntries(
* posts.map((post) => ({
* endpointName: 'getPostById',
* arg: post.id,
* value: post,
* })),
* ),
* )
* }
*
* export const extendedApiSlice = baseApiSlice.injectEndpoints({
* endpoints: (build) => ({
* getPostsByUserId: build.query<PostsApiResponse, QueryArgument>({
* query: (userId) => `/posts/user/${userId}`,
*
* onQueryStarted: updatePostOnFulfilled,
* }),
* }),
* })
* ```
*
* @template ResultType - The type of the result `data` returned by the query.
* @template QueryArgumentType - The type of the argument passed into the query.
* @template BaseQueryFunctionType - The type of the base query function being used.
* @template ReducerPath - The type representing the `reducerPath` for the API slice.
*
* @since 2.4.0
* @public
*/
export type TypedQueryOnQueryStarted<
ResultType,
QueryArgumentType,
BaseQueryFunctionType extends BaseQueryFn,
ReducerPath extends string = string,
> = QueryLifecycleQueryExtraOptions<
ResultType,
QueryArgumentType,
BaseQueryFunctionType,
ReducerPath
>['onQueryStarted']
/**
* Provides a way to define a strongly-typed version of
* {@linkcode QueryLifecycleMutationExtraOptions.onQueryStarted | onQueryStarted}
* for a specific mutation.
*
* @example
* <caption>#### __Create and reuse a strongly-typed `onQueryStarted` function__</caption>
*
* ```ts
* import type { TypedMutationOnQueryStarted } from '@reduxjs/toolkit/query'
* import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
*
* type Post = {
* id: number
* title: string
* userId: number
* }
*
* type PostsApiResponse = {
* posts: Post[]
* total: number
* skip: number
* limit: number
* }
*
* type QueryArgument = Pick<Post, 'id'> & Partial<Post>
*
* type BaseQueryFunction = ReturnType<typeof fetchBaseQuery>
*
* const baseApiSlice = createApi({
* baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com' }),
* reducerPath: 'postsApi',
* tagTypes: ['Posts'],
* endpoints: (build) => ({
* getPosts: build.query<PostsApiResponse, void>({
* query: () => `/posts`,
* }),
*
* getPostById: build.query<Post, number>({
* query: (postId) => `/posts/${postId}`,
* }),
* }),
* })
*
* const updatePostOnFulfilled: TypedMutationOnQueryStarted<
* Post,
* QueryArgument,
* BaseQueryFunction,
* 'postsApi'
* > = async ({ id, ...patch }, { dispatch, queryFulfilled }) => {
* const patchCollection = dispatch(
* baseApiSlice.util.updateQueryData('getPostById', id, (draftPost) => {
* Object.assign(draftPost, patch)
* }),
* )
*
* try {
* await queryFulfilled
* } catch {
* patchCollection.undo()
* }
* }
*
* export const extendedApiSlice = baseApiSlice.injectEndpoints({
* endpoints: (build) => ({
* addPost: build.mutation<Post, Omit<QueryArgument, 'id'>>({
* query: (body) => ({
* url: `posts/add`,
* method: 'POST',
* body,
* }),
*
* onQueryStarted: updatePostOnFulfilled,
* }),
*
* updatePost: build.mutation<Post, QueryArgument>({
* query: ({ id, ...patch }) => ({
* url: `post/${id}`,
* method: 'PATCH',
* body: patch,
* }),
*
* onQueryStarted: updatePostOnFulfilled,
* }),
* }),
* })
* ```
*
* @template ResultType - The type of the result `data` returned by the query.
* @template QueryArgumentType - The type of the argument passed into the query.
* @template BaseQueryFunctionType - The type of the base query function being used.
* @template ReducerPath - The type representing the `reducerPath` for the API slice.
*
* @since 2.4.0
* @public
*/
export type TypedMutationOnQueryStarted<
ResultType,
QueryArgumentType,
BaseQueryFunctionType extends BaseQueryFn,
ReducerPath extends string = string,
> = QueryLifecycleMutationExtraOptions<
ResultType,
QueryArgumentType,
BaseQueryFunctionType,
ReducerPath
>['onQueryStarted']
export const buildQueryLifecycleHandler: InternalHandlerBuilder = ({
api,
context,
queryThunk,
mutationThunk,
}) => {
const isPendingThunk = isPending(queryThunk, mutationThunk)
const isRejectedThunk = isRejected(queryThunk, mutationThunk)
const isFullfilledThunk = isFulfilled(queryThunk, mutationThunk)
type CacheLifecycle = {
resolve(value: { data: unknown; meta: unknown }): unknown
reject(value: QueryFulfilledRejectionReason<any>): unknown
}
const lifecycleMap: Record<string, CacheLifecycle> = {}
const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
if (isPendingThunk(action)) {
const {
requestId,
arg: { endpointName, originalArgs },
} = action.meta
const endpointDefinition = context.endpointDefinitions[endpointName]
const onQueryStarted = endpointDefinition?.onQueryStarted
if (onQueryStarted) {
const lifecycle = {} as CacheLifecycle
const queryFulfilled =
new (Promise as PromiseConstructorWithKnownReason)<
{ data: unknown; meta: unknown },
QueryFulfilledRejectionReason<any>
>((resolve, reject) => {
lifecycle.resolve = resolve
lifecycle.reject = reject
})
// prevent uncaught promise rejections from happening.
// if the original promise is used in any way, that will create a new promise that will throw again
queryFulfilled.catch(() => {})
lifecycleMap[requestId] = lifecycle
const selector = (api.endpoints[endpointName] as any).select(
isAnyQueryDefinition(endpointDefinition) ? originalArgs : requestId,
)
const extra = mwApi.dispatch((_, __, extra) => extra)
const lifecycleApi = {
...mwApi,
getCacheEntry: () => selector(mwApi.getState()),
requestId,
extra,
updateCachedData: (isAnyQueryDefinition(endpointDefinition)
? (updateRecipe: Recipe<any>) =>
mwApi.dispatch(
api.util.updateQueryData(
endpointName as never,
originalArgs as never,
updateRecipe,
),
)
: undefined) as any,
queryFulfilled,
}
onQueryStarted(originalArgs, lifecycleApi as any)
}
} else if (isFullfilledThunk(action)) {
const { requestId, baseQueryMeta } = action.meta
lifecycleMap[requestId]?.resolve({
data: action.payload,
meta: baseQueryMeta,
})
delete lifecycleMap[requestId]
} else if (isRejectedThunk(action)) {
const { requestId, rejectedWithValue, baseQueryMeta } = action.meta
lifecycleMap[requestId]?.reject({
error: action.payload ?? action.error,
isUnhandledError: !rejectedWithValue,
meta: baseQueryMeta as any,
})
delete lifecycleMap[requestId]
}
}
return handler
}