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 "createAsyncThunkCreator" with option for customizing default error serializer #4549

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
48 changes: 48 additions & 0 deletions docs/api/createAsyncThunk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThunkApiConfig>({
serializeError(error) {
return {
...miniSerializeError(error),
isAxiosError: isAxiosError(error),
}
},
idGenerator: () => uuidv4(),
})
```
74 changes: 56 additions & 18 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,25 +487,62 @@ type CreateAsyncThunk<CurriedThunkApiConfig extends AsyncThunkConfig> = {
>
}

export const createAsyncThunk = /* @__PURE__ */ (() => {
/**
* @public
*/
export type CreateAsyncThunkCreatorOptions<
ThunkApiConfig extends AsyncThunkConfig,
> = Pick<
AsyncThunkOptions<unknown, ThunkApiConfig>,
'serializeError' | 'idGenerator'
>

export function createAsyncThunkCreator<
CreatorThunkApiConfig extends AsyncThunkConfig = {},
>(
creatorOptions?: CreateAsyncThunkCreatorOptions<CreatorThunkApiConfig>,
): CreateAsyncThunk<CreatorThunkApiConfig> {
function createAsyncThunk<
Returned,
ThunkArg,
ThunkApiConfig extends AsyncThunkConfig,
CallThunkApiConfig extends AsyncThunkConfig,
>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<
Returned,
ThunkArg,
ThunkApiConfig
OverrideThunkApiConfigs<CreatorThunkApiConfig, CallThunkApiConfig>
>,
options?: AsyncThunkOptions<
ThunkArg,
OverrideThunkApiConfigs<CreatorThunkApiConfig, CallThunkApiConfig>
>,
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>,
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> {
): AsyncThunk<
Returned,
ThunkArg,
OverrideThunkApiConfigs<CreatorThunkApiConfig, CallThunkApiConfig>
> {
type ThunkApiConfig = OverrideThunkApiConfigs<
CreatorThunkApiConfig,
CallThunkApiConfig
>
type RejectedValue = GetRejectValue<ThunkApiConfig>
type PendingMeta = GetPendingMeta<ThunkApiConfig>
type FulfilledMeta = GetFulfilledMeta<ThunkApiConfig>
type RejectedMeta = GetRejectedMeta<ThunkApiConfig>

const {
serializeError = miniSerializeError,
// nanoid needs to be wrapped because it accepts a size argument
idGenerator = () => nanoid(),
getPendingMeta,
condition,
dispatchConditionRejection,
} = {
...creatorOptions,
...options,
} as AsyncThunkOptions<ThunkArg, ThunkApiConfig>

const fulfilled: AsyncThunkFulfilledActionCreator<
Returned,
ThunkArg,
Expand Down Expand Up @@ -553,7 +590,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
meta?: RejectedMeta,
) => ({
payload,
error: ((options && options.serializeError) || miniSerializeError)(
error: serializeError(
error || 'Rejected',
) as GetSerializedErrorType<ThunkApiConfig>,
meta: {
Expand All @@ -572,9 +609,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
arg: ThunkArg,
): AsyncThunkAction<Returned, ThunkArg, Required<ThunkApiConfig>> {
return (dispatch, getState, extra) => {
const requestId = options?.idGenerator
? options.idGenerator(arg)
: nanoid()
const requestId = idGenerator(arg)

const abortController = new AbortController()
let abortHandler: (() => void) | undefined
Expand All @@ -588,7 +623,10 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
const promise = (async function () {
let finalAction: ReturnType<typeof fulfilled | typeof rejected>
try {
let conditionResult = options?.condition?.(arg, { getState, extra })
let conditionResult = condition?.(arg, {
getState,
extra,
})
if (isThenable(conditionResult)) {
conditionResult = await conditionResult
}
Expand All @@ -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([
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -702,10 +736,14 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
},
)
}

createAsyncThunk.withTypes = () => createAsyncThunk

return createAsyncThunk as CreateAsyncThunk<AsyncThunkConfig>
})()
return createAsyncThunk as CreateAsyncThunk<CreatorThunkApiConfig>
}

export const createAsyncThunk =
/* @__PURE__ */ createAsyncThunkCreator<AsyncThunkConfig>()

interface UnwrappableAction {
payload: any
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type {

export {
createAsyncThunk,
createAsyncThunkCreator,
unwrapResult,
miniSerializeError,
} from './createAsyncThunk'
Expand Down
25 changes: 25 additions & 0 deletions packages/toolkit/src/tests/createAsyncThunk.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
Action,
AsyncThunk,
SerializedError,
ThunkDispatch,
Expand All @@ -7,6 +8,7 @@ import type {
import {
configureStore,
createAsyncThunk,
createAsyncThunkCreator,
createReducer,
createSlice,
unwrapResult,
Expand Down Expand Up @@ -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<typeof store.getState>
type AppDispatch = typeof store.dispatch

const createAsyncThunk = createAsyncThunkCreator<{
state: RootState
dispatch: AppDispatch
}>()

const thunk = createAsyncThunk(
'test',
(arg: string, { dispatch, getState }) => {
expectTypeOf(dispatch).toEqualTypeOf<AppDispatch>()
expectTypeOf(getState).toEqualTypeOf<() => RootState>()
},
)

store.dispatch(thunk('test'))
})
})
124 changes: 124 additions & 0 deletions packages/toolkit/src/tests/createAsyncThunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createReducer,
unwrapResult,
miniSerializeError,
createAsyncThunkCreator,
} from '@reduxjs/toolkit'
import { vi } from 'vitest'

Expand Down Expand Up @@ -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<number, string>('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<number, string>('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')
})
})