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

51 changes: 51 additions & 0 deletions docs/api/createAsyncThunk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,54 @@ const UsersComponent = (props: { id: string }) => {
// render UI here
}
```

## `createAsyncThunkCreator`

### Options

An object with the following optional fields:

- `serializeError(error: any, defaultSerializer: (error: any) => SerializedError) => GetSerializedErrorType<ThunkApiConfig>` to replace or extend the default internal serializer method with your own serialization logic.

### Return Value

`createAsyncThunkCreator` returns a Redux thunk action creator with customized options. Currently, the only option is `serializeError`.

### Example

```ts no-transpile
import { createAsyncThunkCreator, SerializedError } from '@reduxjs/toolkit'

export interface AppSerializedError extends SerializedError {
isAxiosError?: boolean
}

type ThunkApiConfig = {
state: RootState
serializedErrorType: AppSerializedError
}

const createAppAsyncThunkCreator = createAsyncThunkCreator<ThunkApiConfig>({
serializeError(error, defaultSerializer) {
const serializedError = defaultSerializer(error) as AppSerializedError
serializedError.isAxiosError = error.isAxiosError
return serializedError
},
})

function createAppAsyncThunk<
Returned,
ThunkArg = void,
T extends ThunkApiConfig = ThunkApiConfig,
>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, T>,
options?: AsyncThunkOptions<ThunkArg, T>,
): AsyncThunk<Returned, ThunkArg, T> {
return createAppAsyncThunkCreator<Returned, ThunkArg, T>(
typePrefix,
payloadCreator,
options,
)
}
```
70 changes: 63 additions & 7 deletions packages/toolkit/src/createAsyncThunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class FulfillWithMeta<Payload, FulfilledMeta> {
*/
export const miniSerializeError = (value: any): SerializedError => {
if (typeof value === 'object' && value !== null) {
const simpleError: SerializedError = {}
const simpleError = {} as Record<string, string>
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved
for (const property of commonProperties) {
if (typeof value[property] === 'string') {
simpleError[property] = value[property]
Expand Down Expand Up @@ -487,11 +487,21 @@ type CreateAsyncThunk<CurriedThunkApiConfig extends AsyncThunkConfig> = {
>
}

export const createAsyncThunk = /* @__PURE__ */ (() => {
type InternalCreateAsyncThunkCreatorOptions<
ThunkApiConfig extends AsyncThunkConfig,
> = {
serializeError?: ErrorSerializer<ThunkApiConfig>
}

function internalCreateAsyncThunkCreator<
CreatorThunkApiConfig extends AsyncThunkConfig = {},
>(
creatorOptions?: InternalCreateAsyncThunkCreatorOptions<CreatorThunkApiConfig>,
): CreateAsyncThunk<CreatorThunkApiConfig> {
function createAsyncThunk<
Returned,
ThunkArg,
ThunkApiConfig extends AsyncThunkConfig,
ThunkApiConfig extends CreatorThunkApiConfig,
>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<
Expand Down Expand Up @@ -542,6 +552,18 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
}),
)

function getError(x: unknown): GetSerializedErrorType<ThunkApiConfig> {
if (options && options.serializeError) {
return options.serializeError(x)
}

if (creatorOptions && creatorOptions.serializeError) {
return creatorOptions.serializeError(x, miniSerializeError)
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved
}

return miniSerializeError(x) as GetSerializedErrorType<ThunkApiConfig>
}

const rejected: AsyncThunkRejectedActionCreator<ThunkArg, ThunkApiConfig> =
createAction(
typePrefix + '/rejected',
Expand All @@ -553,9 +575,7 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
meta?: RejectedMeta,
) => ({
payload,
error: ((options && options.serializeError) || miniSerializeError)(
error || 'Rejected',
) as GetSerializedErrorType<ThunkApiConfig>,
error: getError(error || 'Rejected'),
meta: {
...((meta as any) || {}),
arg,
Expand Down Expand Up @@ -588,7 +608,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 = options?.condition?.(arg, {
getState,
extra,
})
if (isThenable(conditionResult)) {
conditionResult = await conditionResult
}
Expand Down Expand Up @@ -702,9 +725,14 @@ export const createAsyncThunk = /* @__PURE__ */ (() => {
},
)
}

createAsyncThunk.withTypes = () => createAsyncThunk

return createAsyncThunk as CreateAsyncThunk<AsyncThunkConfig>
}

export const createAsyncThunk = /* @__PURE__ */ (() => {
return internalCreateAsyncThunkCreator() as CreateAsyncThunk<AsyncThunkConfig>
})()
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved

interface UnwrappableAction {
Expand Down Expand Up @@ -744,3 +772,31 @@ function isThenable(value: any): value is PromiseLike<any> {
typeof value.then === 'function'
)
}

/**
* An error serializer function that can be used to serialize errors into plain objects.
*
* @param error - The error to serialize
* @param defaultSerializer - The original default serializer `miniSerializeError` https://redux-toolkit.js.org/api/other-exports/#miniserializeerror
*
* @public
*/
type ErrorSerializer<ThunkApiConfig extends AsyncThunkConfig> = (
error: any,
defaultSerializer: (error: any) => SerializedError,
) => GetSerializedErrorType<ThunkApiConfig>

/**
* @public
*/
type CreateAsyncThunkCreatorOptions<ThunkApiConfig extends AsyncThunkConfig> = {
serializeError?: ErrorSerializer<ThunkApiConfig>
}

export const createAsyncThunkCreator = /* @__PURE__ */ (() => {
EskiMojo14 marked this conversation as resolved.
Show resolved Hide resolved
return <ThunkApiConfig extends AsyncThunkConfig = {}>(
options: CreateAsyncThunkCreatorOptions<ThunkApiConfig>,
): CreateAsyncThunk<ThunkApiConfig> => {
return internalCreateAsyncThunkCreator(options)
}
})()
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
83 changes: 83 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,85 @@ 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 '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: 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: '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!')
})
})