diff --git a/docs/api/createAsyncThunk.mdx b/docs/api/createAsyncThunk.mdx index 98d7fbb395..dc4ceae2db 100644 --- a/docs/api/createAsyncThunk.mdx +++ b/docs/api/createAsyncThunk.mdx @@ -769,3 +769,51 @@ const UsersComponent = (props: { id: string }) => { // render UI here } ``` + +## `createAsyncThunkCreator` + +Create a customised version of `createAsyncThunk` with defaulted options. + +Options specified when calling `createAsyncThunk` will override options specified in `createAsyncThunkCreator`. + +### Options + +An object with the following optional fields: + +- `serializeError(error: unknown) => any` to replace the internal `miniSerializeError` method with your own serialization logic. +- `idGenerator(arg: unknown) => string`: a function to use when generating the `requestId` for the request sequence. Defaults to use [nanoid](./otherExports.mdx/#nanoid), but you can implement your own ID generation logic. + +### Return Value + +A version of `createAsyncThunk` that has options defaulted to the values provided. + +### Example + +```ts no-transpile +import { + createAsyncThunkCreator, + miniSerializeError, + SerializedError, +} from '@reduxjs/toolkit' +import { isAxiosError } from 'axios' +import { v4 as uuidv4 } from 'uuid' + +export interface AppSerializedError extends SerializedError { + isAxiosError?: boolean +} + +type ThunkApiConfig = { + state: RootState + serializedErrorType: AppSerializedError +} + +export const createAppAsyncThunk = createAsyncThunkCreator({ + serializeError(error) { + return { + ...miniSerializeError(error), + isAxiosError: isAxiosError(error), + } + }, + idGenerator: () => uuidv4(), +}) +``` diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 4534165869..aba27a2f36 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -487,25 +487,62 @@ type CreateAsyncThunk = { > } -export const createAsyncThunk = /* @__PURE__ */ (() => { +/** + * @public + */ +export type CreateAsyncThunkCreatorOptions< + ThunkApiConfig extends AsyncThunkConfig, +> = Pick< + AsyncThunkOptions, + 'serializeError' | 'idGenerator' +> + +export function createAsyncThunkCreator< + CreatorThunkApiConfig extends AsyncThunkConfig = {}, +>( + creatorOptions?: CreateAsyncThunkCreatorOptions, +): CreateAsyncThunk { function createAsyncThunk< Returned, ThunkArg, - ThunkApiConfig extends AsyncThunkConfig, + CallThunkApiConfig extends AsyncThunkConfig, >( typePrefix: string, payloadCreator: AsyncThunkPayloadCreator< Returned, ThunkArg, - ThunkApiConfig + OverrideThunkApiConfigs + >, + options?: AsyncThunkOptions< + ThunkArg, + OverrideThunkApiConfigs >, - options?: AsyncThunkOptions, - ): AsyncThunk { + ): AsyncThunk< + Returned, + ThunkArg, + OverrideThunkApiConfigs + > { + type ThunkApiConfig = OverrideThunkApiConfigs< + CreatorThunkApiConfig, + CallThunkApiConfig + > type RejectedValue = GetRejectValue type PendingMeta = GetPendingMeta type FulfilledMeta = GetFulfilledMeta type RejectedMeta = GetRejectedMeta + const { + serializeError = miniSerializeError, + // nanoid needs to be wrapped because it accepts a size argument + idGenerator = () => nanoid(), + getPendingMeta, + condition, + dispatchConditionRejection, + } = { + ...creatorOptions, + ...options, + } as AsyncThunkOptions + const fulfilled: AsyncThunkFulfilledActionCreator< Returned, ThunkArg, @@ -553,7 +590,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { meta?: RejectedMeta, ) => ({ payload, - error: ((options && options.serializeError) || miniSerializeError)( + error: serializeError( error || 'Rejected', ) as GetSerializedErrorType, meta: { @@ -572,9 +609,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { arg: ThunkArg, ): AsyncThunkAction> { return (dispatch, getState, extra) => { - const requestId = options?.idGenerator - ? options.idGenerator(arg) - : nanoid() + const requestId = idGenerator(arg) const abortController = new AbortController() let abortHandler: (() => void) | undefined @@ -588,7 +623,10 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { const promise = (async function () { let finalAction: ReturnType try { - let conditionResult = options?.condition?.(arg, { getState, extra }) + let conditionResult = condition?.(arg, { + getState, + extra, + }) if (isThenable(conditionResult)) { conditionResult = await conditionResult } @@ -614,10 +652,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { pending( requestId, arg, - options?.getPendingMeta?.( - { requestId, arg }, - { getState, extra }, - ), + getPendingMeta?.({ requestId, arg }, { getState, extra }), ) as any, ) finalAction = await Promise.race([ @@ -666,8 +701,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { // and https://github.com/reduxjs/redux-toolkit/blob/e85eb17b39a2118d859f7b7746e0f3fee523e089/docs/tutorials/advanced-tutorial.md#async-error-handling-logic-in-thunks const skipDispatch = - options && - !options.dispatchConditionRejection && + !dispatchConditionRejection && rejected.match(finalAction) && (finalAction as any).meta.condition @@ -702,10 +736,14 @@ export const createAsyncThunk = /* @__PURE__ */ (() => { }, ) } + createAsyncThunk.withTypes = () => createAsyncThunk - return createAsyncThunk as CreateAsyncThunk -})() + return createAsyncThunk as CreateAsyncThunk +} + +export const createAsyncThunk = + /* @__PURE__ */ createAsyncThunkCreator() interface UnwrappableAction { payload: any diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 1883310dc2..e784e21050 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -121,6 +121,7 @@ export type { export { createAsyncThunk, + createAsyncThunkCreator, unwrapResult, miniSerializeError, } from './createAsyncThunk' diff --git a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts index 7f961666e9..9d6dafdb40 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test-d.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test-d.ts @@ -1,4 +1,5 @@ import type { + Action, AsyncThunk, SerializedError, ThunkDispatch, @@ -7,6 +8,7 @@ import type { import { configureStore, createAsyncThunk, + createAsyncThunkCreator, createReducer, createSlice, unwrapResult, @@ -888,4 +890,27 @@ describe('type tests', () => { expectTypeOf(ret.meta).not.toHaveProperty('extraProp') } }) + test('createAsyncThunkCreator', () => { + const store = configureStore({ + reducer: (state: Action[] = [], action) => [...state, action], + }) + + type RootState = ReturnType + type AppDispatch = typeof store.dispatch + + const createAsyncThunk = createAsyncThunkCreator<{ + state: RootState + dispatch: AppDispatch + }>() + + const thunk = createAsyncThunk( + 'test', + (arg: string, { dispatch, getState }) => { + expectTypeOf(dispatch).toEqualTypeOf() + expectTypeOf(getState).toEqualTypeOf<() => RootState>() + }, + ) + + store.dispatch(thunk('test')) + }) }) diff --git a/packages/toolkit/src/tests/createAsyncThunk.test.ts b/packages/toolkit/src/tests/createAsyncThunk.test.ts index ef6ae71838..cfcc2f2cc7 100644 --- a/packages/toolkit/src/tests/createAsyncThunk.test.ts +++ b/packages/toolkit/src/tests/createAsyncThunk.test.ts @@ -5,6 +5,7 @@ import { createReducer, unwrapResult, miniSerializeError, + createAsyncThunkCreator, } from '@reduxjs/toolkit' import { vi } from 'vitest' @@ -991,3 +992,126 @@ describe('meta', () => { expect(thunk.fulfilled.type).toBe('a/fulfilled') }) }) +describe('createAsyncThunkCreator', () => { + test('custom default serializeError only', async () => { + function serializeError() { + return 'serialized!' + } + const errorObject = 'something else!' + + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) + + const createAsyncThunk = createAsyncThunkCreator<{ + serializedErrorType: string + }>({ + serializeError, + }) + + const asyncThunk = createAsyncThunk< + unknown, + void, + { serializedErrorType: string } + >('test', () => Promise.reject(errorObject), { serializeError }) + const rejected = await store.dispatch(asyncThunk()) + if (!asyncThunk.rejected.match(rejected)) { + throw new Error() + } + + const expectation = { + type: 'test/rejected', + payload: undefined, + error: 'serialized!', + meta: expect.any(Object), + } + expect(rejected).toEqual(expectation) + expect(store.getState()[2]).toEqual(expectation) + expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) + }) + + test('custom default serializeError with thunk-level override', async () => { + function defaultSerializeError() { + return 'serialized by default serializer!' + } + function thunkSerializeError() { + return { message: 'serialized by thunk serializer!' } + } + const errorObject = 'something else!' + + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) + + const createAsyncThunk = createAsyncThunkCreator<{ + serializedErrorType: string + }>({ + serializeError: defaultSerializeError, + }) + + const thunk = createAsyncThunk< + unknown, + void, + { + serializedErrorType: { + message: string + } + } + >('test', () => Promise.reject(errorObject), { + serializeError: thunkSerializeError, + }) + const rejected = await store.dispatch(thunk()) + if (!thunk.rejected.match(rejected)) { + throw new Error() + } + + const thunkLevelExpectation = { + type: 'test/rejected', + payload: undefined, + error: { message: 'serialized by thunk serializer!' }, + meta: expect.any(Object), + } + + expect(rejected).toEqual(thunkLevelExpectation) + expect(store.getState()[2]).toEqual(thunkLevelExpectation) + expect(rejected.error).not.toEqual(miniSerializeError(errorObject)) + expect(rejected.error).not.toEqual('serialized by default serializer!') + }) + test('custom default idGenerator only', async () => { + function idGenerator(arg: unknown) { + return `${arg}` + } + const createAsyncThunk = createAsyncThunkCreator({ + idGenerator, + }) + const asyncThunk = createAsyncThunk('test', async () => 1) + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) + const promise = store.dispatch(asyncThunk('testArg')) + expect(promise.requestId).toBe('testArg') + const result = await promise + expect(result.meta.requestId).toBe('testArg') + }) + test('custom default idGenerator with thunk-level override', async () => { + function defaultIdGenerator(arg: unknown) { + return `default-${arg}` + } + function thunkIdGenerator(arg: unknown) { + return `thunk-${arg}` + } + const createAsyncThunk = createAsyncThunkCreator({ + idGenerator: defaultIdGenerator, + }) + const thunk = createAsyncThunk('test', async () => 1, { + idGenerator: thunkIdGenerator, + }) + const store = configureStore({ + reducer: (state = [], action) => [...state, action], + }) + const promise = store.dispatch(thunk('testArg')) + expect(promise.requestId).toBe('thunk-testArg') + const result = await promise + expect(result.meta.requestId).toBe('thunk-testArg') + }) +})