Skip to content

Commit b99dc11

Browse files
authored
add addAsyncThunk method to reducer map builder (#5007)
* add addAsyncThunk method to reducer map builder * fix type tests for TS 5.5-7
1 parent a3860f1 commit b99dc11

File tree

5 files changed

+418
-105
lines changed

5 files changed

+418
-105
lines changed

packages/toolkit/src/createSlice.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import type {
2323
ReducerWithInitialState,
2424
} from './createReducer'
2525
import { createReducer } from './createReducer'
26-
import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders'
26+
import type {
27+
ActionReducerMapBuilder,
28+
AsyncThunkReducers,
29+
TypedActionCreator,
30+
} from './mapBuilders'
2731
import { executeReducerBuilderCallback } from './mapBuilders'
2832
import type { Id, TypeGuard } from './tsHelpers'
2933
import { getOrInsertComputed } from './utils'
@@ -300,25 +304,7 @@ type AsyncThunkSliceReducerConfig<
300304
ThunkArg extends any,
301305
Returned = unknown,
302306
ThunkApiConfig extends AsyncThunkConfig = {},
303-
> = {
304-
pending?: CaseReducer<
305-
State,
306-
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['pending']>
307-
>
308-
rejected?: CaseReducer<
309-
State,
310-
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected']>
311-
>
312-
fulfilled?: CaseReducer<
313-
State,
314-
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['fulfilled']>
315-
>
316-
settled?: CaseReducer<
317-
State,
318-
ReturnType<
319-
AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected' | 'fulfilled']
320-
>
321-
>
307+
> = AsyncThunkReducers<State, ThunkArg, Returned, ThunkApiConfig> & {
322308
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
323309
}
324310

packages/toolkit/src/mapBuilders.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ import type {
55
ActionMatcherDescriptionCollection,
66
} from './createReducer'
77
import type { TypeGuard } from './tsHelpers'
8+
import type { AsyncThunk, AsyncThunkConfig } from './createAsyncThunk'
9+
10+
export type AsyncThunkReducers<
11+
State,
12+
ThunkArg extends any,
13+
Returned = unknown,
14+
ThunkApiConfig extends AsyncThunkConfig = {},
15+
> = {
16+
pending?: CaseReducer<
17+
State,
18+
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['pending']>
19+
>
20+
rejected?: CaseReducer<
21+
State,
22+
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected']>
23+
>
24+
fulfilled?: CaseReducer<
25+
State,
26+
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['fulfilled']>
27+
>
28+
settled?: CaseReducer<
29+
State,
30+
ReturnType<
31+
AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected' | 'fulfilled']
32+
>
33+
>
34+
}
835

936
export type TypedActionCreator<Type extends string> = {
1037
(...args: any[]): Action<Type>
@@ -31,7 +58,7 @@ export interface ActionReducerMapBuilder<State> {
3158
/**
3259
* Adds a case reducer to handle a single exact action type.
3360
* @remarks
34-
* All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
61+
* All calls to `builder.addCase` must come before any calls to `builder.addAsyncThunk`, `builder.addMatcher` or `builder.addDefaultCase`.
3562
* @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type.
3663
* @param reducer - The actual case reducer function.
3764
*/
@@ -40,12 +67,28 @@ export interface ActionReducerMapBuilder<State> {
4067
reducer: CaseReducer<State, A>,
4168
): ActionReducerMapBuilder<State>
4269

70+
/**
71+
* Adds case reducers to handle actions based on a `AsyncThunk` action creator.
72+
* @remarks
73+
* All calls to `builder.addAsyncThunk` must come before after any calls to `builder.addCase` and before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
74+
* @param asyncThunk - The async thunk action creator itself.
75+
* @param reducers - A mapping from each of the `AsyncThunk` action types to the case reducer that should handle those actions.
76+
*/
77+
addAsyncThunk<
78+
Returned,
79+
ThunkArg,
80+
ThunkApiConfig extends AsyncThunkConfig = {},
81+
>(
82+
asyncThunk: AsyncThunk<Returned, ThunkArg, ThunkApiConfig>,
83+
reducers: AsyncThunkReducers<State, ThunkArg, Returned, ThunkApiConfig>,
84+
): Omit<ActionReducerMapBuilder<State>, 'addCase'>
85+
4386
/**
4487
* Allows you to match your incoming actions against your own filter function instead of only the `action.type` property.
4588
* @remarks
4689
* If multiple matcher reducers match, all of them will be executed in the order
4790
* they were defined in - even if a case reducer already matched.
48-
* All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and before any calls to `builder.addDefaultCase`.
91+
* All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and `builder.addAsyncThunk` and before any calls to `builder.addDefaultCase`.
4992
* @param matcher - A matcher function. In TypeScript, this should be a [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
5093
* function
5194
* @param reducer - The actual case reducer function.
@@ -99,7 +142,7 @@ const reducer = createReducer(initialState, (builder) => {
99142
addMatcher<A>(
100143
matcher: TypeGuard<A> | ((action: any) => boolean),
101144
reducer: CaseReducer<State, A extends Action ? A : A & Action>,
102-
): Omit<ActionReducerMapBuilder<State>, 'addCase'>
145+
): Omit<ActionReducerMapBuilder<State>, 'addCase' | 'addAsyncThunk'>
103146

104147
/**
105148
* Adds a "default case" reducer that is executed if no case reducer and no matcher
@@ -173,6 +216,35 @@ export function executeReducerBuilderCallback<S>(
173216
actionsMap[type] = reducer
174217
return builder
175218
},
219+
addAsyncThunk<
220+
Returned,
221+
ThunkArg,
222+
ThunkApiConfig extends AsyncThunkConfig = {},
223+
>(
224+
asyncThunk: AsyncThunk<Returned, ThunkArg, ThunkApiConfig>,
225+
reducers: AsyncThunkReducers<S, ThunkArg, Returned, ThunkApiConfig>,
226+
) {
227+
if (process.env.NODE_ENV !== 'production') {
228+
// since this uses both action cases and matchers, we can't enforce the order in runtime other than checking for default case
229+
if (defaultCaseReducer) {
230+
throw new Error(
231+
'`builder.addAsyncThunk` should only be called before calling `builder.addDefaultCase`',
232+
)
233+
}
234+
}
235+
if (reducers.pending)
236+
actionsMap[asyncThunk.pending.type] = reducers.pending
237+
if (reducers.rejected)
238+
actionsMap[asyncThunk.rejected.type] = reducers.rejected
239+
if (reducers.fulfilled)
240+
actionsMap[asyncThunk.fulfilled.type] = reducers.fulfilled
241+
if (reducers.settled)
242+
actionMatchers.push({
243+
matcher: asyncThunk.settled,
244+
reducer: reducers.settled,
245+
})
246+
return builder
247+
},
176248
addMatcher<A>(
177249
matcher: TypeGuard<A>,
178250
reducer: CaseReducer<S, A extends Action ? A : A & Action>,

packages/toolkit/src/tests/createReducer.test.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import type {
77
} from '@reduxjs/toolkit'
88
import {
99
createAction,
10+
createAsyncThunk,
1011
createNextState,
1112
createReducer,
1213
isPlainObject,
1314
} from '@reduxjs/toolkit'
15+
import { waitMs } from './utils/helpers'
1416

1517
interface Todo {
1618
text: string
@@ -39,6 +41,8 @@ type ToggleTodoReducer = CaseReducer<
3941

4042
type CreateReducer = typeof createReducer
4143

44+
const addTodoThunk = createAsyncThunk('todos/add', (todo: Todo) => todo)
45+
4246
describe('createReducer', () => {
4347
describe('given impure reducers with immer', () => {
4448
const addTodo: AddTodoReducer = (state, action) => {
@@ -341,24 +345,24 @@ describe('createReducer', () => {
341345
expect(reducer(5, decrement(5))).toBe(0)
342346
})
343347
test('will throw if the same type is used twice', () => {
344-
expect(() =>
345-
createReducer(0, (builder) =>
348+
expect(() => {
349+
createReducer(0, (builder) => {
346350
builder
347351
.addCase(increment, (state, action) => state + action.payload)
348352
.addCase(increment, (state, action) => state + action.payload)
349-
.addCase(decrement, (state, action) => state - action.payload),
350-
),
351-
).toThrowErrorMatchingInlineSnapshot(
353+
.addCase(decrement, (state, action) => state - action.payload)
354+
})
355+
}).toThrowErrorMatchingInlineSnapshot(
352356
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
353357
)
354-
expect(() =>
355-
createReducer(0, (builder) =>
358+
expect(() => {
359+
createReducer(0, (builder) => {
356360
builder
357361
.addCase(increment, (state, action) => state + action.payload)
358362
.addCase('increment', (state) => state + 1)
359-
.addCase(decrement, (state, action) => state - action.payload),
360-
),
361-
).toThrowErrorMatchingInlineSnapshot(
363+
.addCase(decrement, (state, action) => state - action.payload)
364+
})
365+
}).toThrowErrorMatchingInlineSnapshot(
362366
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
363367
)
364368
})
@@ -369,14 +373,14 @@ describe('createReducer', () => {
369373
payload,
370374
})
371375
customActionCreator.type = ''
372-
expect(() =>
373-
createReducer(0, (builder) =>
376+
expect(() => {
377+
createReducer(0, (builder) => {
374378
builder.addCase(
375379
customActionCreator,
376380
(state, action) => state + action.payload,
377-
),
378-
),
379-
).toThrowErrorMatchingInlineSnapshot(
381+
)
382+
})
383+
}).toThrowErrorMatchingInlineSnapshot(
380384
`[Error: \`builder.addCase\` cannot be called with an empty action type]`,
381385
)
382386
})
@@ -529,6 +533,56 @@ describe('createReducer', () => {
529533
)
530534
})
531535
})
536+
describe('builder "addAsyncThunk" method', () => {
537+
const initialState = { todos: [] as Todo[], loading: false, errored: false }
538+
test('uses the matching reducer for each action type', () => {
539+
const reducer = createReducer(initialState, (builder) =>
540+
builder.addAsyncThunk(addTodoThunk, {
541+
pending(state) {
542+
state.loading = true
543+
},
544+
fulfilled(state, action) {
545+
state.todos.push(action.payload)
546+
},
547+
rejected(state) {
548+
state.errored = true
549+
},
550+
settled(state) {
551+
state.loading = false
552+
},
553+
}),
554+
)
555+
const todo: Todo = { text: 'test' }
556+
expect(reducer(undefined, addTodoThunk.pending('test', todo))).toEqual({
557+
todos: [],
558+
loading: true,
559+
errored: false,
560+
})
561+
expect(
562+
reducer(undefined, addTodoThunk.fulfilled(todo, 'test', todo)),
563+
).toEqual({
564+
todos: [todo],
565+
loading: false,
566+
errored: false,
567+
})
568+
expect(
569+
reducer(undefined, addTodoThunk.rejected(new Error(), 'test', todo)),
570+
).toEqual({
571+
todos: [],
572+
loading: false,
573+
errored: true,
574+
})
575+
})
576+
test('calling addAsyncThunk after addDefaultCase should result in an error in development mode', () => {
577+
expect(() =>
578+
createReducer(initialState, (builder: any) =>
579+
builder.addDefaultCase(() => {}).addAsyncThunk(addTodoThunk, {}),
580+
),
581+
).toThrowErrorMatchingInlineSnapshot(
582+
`[Error: \`builder.addAsyncThunk\` should only be called before calling \`builder.addDefaultCase\`]`,
583+
)
584+
})
585+
})
532586
})
533587

534588
function behavesLikeReducer(todosReducer: TodosReducer) {

packages/toolkit/src/tests/createSlice.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
combineSlices,
77
configureStore,
88
createAction,
9+
createAsyncThunk,
910
createSlice,
1011
} from '@reduxjs/toolkit'
1112

@@ -265,6 +266,58 @@ describe('createSlice', () => {
265266
)
266267
})
267268

269+
test('can be used with addAsyncThunk and async thunks', () => {
270+
const asyncThunk = createAsyncThunk('test', (n: number) => n)
271+
const slice = createSlice({
272+
name: 'counter',
273+
initialState: {
274+
loading: false,
275+
errored: false,
276+
value: 0,
277+
},
278+
reducers: {},
279+
extraReducers: (builder) =>
280+
builder.addAsyncThunk(asyncThunk, {
281+
pending(state) {
282+
state.loading = true
283+
},
284+
fulfilled(state, action) {
285+
state.value = action.payload
286+
},
287+
rejected(state) {
288+
state.errored = true
289+
},
290+
settled(state) {
291+
state.loading = false
292+
},
293+
}),
294+
})
295+
expect(
296+
slice.reducer(undefined, asyncThunk.pending('requestId', 5)),
297+
).toEqual({
298+
loading: true,
299+
errored: false,
300+
value: 0,
301+
})
302+
expect(
303+
slice.reducer(undefined, asyncThunk.fulfilled(5, 'requestId', 5)),
304+
).toEqual({
305+
loading: false,
306+
errored: false,
307+
value: 5,
308+
})
309+
expect(
310+
slice.reducer(
311+
undefined,
312+
asyncThunk.rejected(new Error(), 'requestId', 5),
313+
),
314+
).toEqual({
315+
loading: false,
316+
errored: true,
317+
value: 0,
318+
})
319+
})
320+
268321
test('can be used with addMatcher and type guard functions', () => {
269322
const slice = createSlice({
270323
name: 'counter',

0 commit comments

Comments
 (0)