Skip to content

add addAsyncThunk method to reducer map builder #5007

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 6 additions & 20 deletions packages/toolkit/src/createSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import type {
ReducerWithInitialState,
} from './createReducer'
import { createReducer } from './createReducer'
import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders'
import type {
ActionReducerMapBuilder,
AsyncThunkReducers,
TypedActionCreator,
} from './mapBuilders'
import { executeReducerBuilderCallback } from './mapBuilders'
import type { Id, TypeGuard } from './tsHelpers'
import { getOrInsertComputed } from './utils'
Expand Down Expand Up @@ -300,25 +304,7 @@ type AsyncThunkSliceReducerConfig<
ThunkArg extends any,
Returned = unknown,
ThunkApiConfig extends AsyncThunkConfig = {},
> = {
pending?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['pending']>
>
rejected?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected']>
>
fulfilled?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['fulfilled']>
>
settled?: CaseReducer<
State,
ReturnType<
AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected' | 'fulfilled']
>
>
> = AsyncThunkReducers<State, ThunkArg, Returned, ThunkApiConfig> & {
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
}

Expand Down
78 changes: 75 additions & 3 deletions packages/toolkit/src/mapBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ import type {
ActionMatcherDescriptionCollection,
} from './createReducer'
import type { TypeGuard } from './tsHelpers'
import type { AsyncThunk, AsyncThunkConfig } from './createAsyncThunk'

export type AsyncThunkReducers<
State,
ThunkArg extends any,
Returned = unknown,
ThunkApiConfig extends AsyncThunkConfig = {},
> = {
pending?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['pending']>
>
rejected?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected']>
>
fulfilled?: CaseReducer<
State,
ReturnType<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['fulfilled']>
>
settled?: CaseReducer<
State,
ReturnType<
AsyncThunk<Returned, ThunkArg, ThunkApiConfig>['rejected' | 'fulfilled']
>
>
}

export type TypedActionCreator<Type extends string> = {
(...args: any[]): Action<Type>
Expand All @@ -31,7 +58,7 @@ export interface ActionReducerMapBuilder<State> {
/**
* Adds a case reducer to handle a single exact action type.
* @remarks
* All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
* All calls to `builder.addCase` must come before any calls to `builder.addAsyncThunk`, `builder.addMatcher` or `builder.addDefaultCase`.
* @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.
* @param reducer - The actual case reducer function.
*/
Expand All @@ -40,12 +67,28 @@ export interface ActionReducerMapBuilder<State> {
reducer: CaseReducer<State, A>,
): ActionReducerMapBuilder<State>

/**
* Adds case reducers to handle actions based on a `AsyncThunk` action creator.
* @remarks
* All calls to `builder.addAsyncThunk` must come before after any calls to `builder.addCase` and before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
* @param asyncThunk - The async thunk action creator itself.
* @param reducers - A mapping from each of the `AsyncThunk` action types to the case reducer that should handle those actions.
*/
addAsyncThunk<
Returned,
ThunkArg,
ThunkApiConfig extends AsyncThunkConfig = {},
>(
asyncThunk: AsyncThunk<Returned, ThunkArg, ThunkApiConfig>,
reducers: AsyncThunkReducers<State, ThunkArg, Returned, ThunkApiConfig>,
): Omit<ActionReducerMapBuilder<State>, 'addCase'>

/**
* Allows you to match your incoming actions against your own filter function instead of only the `action.type` property.
* @remarks
* If multiple matcher reducers match, all of them will be executed in the order
* they were defined in - even if a case reducer already matched.
* All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and before any calls to `builder.addDefaultCase`.
* All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and `builder.addAsyncThunk` and before any calls to `builder.addDefaultCase`.
* @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)
* function
* @param reducer - The actual case reducer function.
Expand Down Expand Up @@ -99,7 +142,7 @@ const reducer = createReducer(initialState, (builder) => {
addMatcher<A>(
matcher: TypeGuard<A> | ((action: any) => boolean),
reducer: CaseReducer<State, A extends Action ? A : A & Action>,
): Omit<ActionReducerMapBuilder<State>, 'addCase'>
): Omit<ActionReducerMapBuilder<State>, 'addCase' | 'addAsyncThunk'>

/**
* Adds a "default case" reducer that is executed if no case reducer and no matcher
Expand Down Expand Up @@ -173,6 +216,35 @@ export function executeReducerBuilderCallback<S>(
actionsMap[type] = reducer
return builder
},
addAsyncThunk<
Returned,
ThunkArg,
ThunkApiConfig extends AsyncThunkConfig = {},
>(
asyncThunk: AsyncThunk<Returned, ThunkArg, ThunkApiConfig>,
reducers: AsyncThunkReducers<S, ThunkArg, Returned, ThunkApiConfig>,
) {
if (process.env.NODE_ENV !== 'production') {
// since this uses both action cases and matchers, we can't enforce the order in runtime other than checking for default case
if (defaultCaseReducer) {
throw new Error(
'`builder.addAsyncThunk` should only be called before calling `builder.addDefaultCase`',
)
}
}
if (reducers.pending)
actionsMap[asyncThunk.pending.type] = reducers.pending
if (reducers.rejected)
actionsMap[asyncThunk.rejected.type] = reducers.rejected
if (reducers.fulfilled)
actionsMap[asyncThunk.fulfilled.type] = reducers.fulfilled
if (reducers.settled)
actionMatchers.push({
matcher: asyncThunk.settled,
reducer: reducers.settled,
})
return builder
},
addMatcher<A>(
matcher: TypeGuard<A>,
reducer: CaseReducer<S, A extends Action ? A : A & Action>,
Expand Down
84 changes: 69 additions & 15 deletions packages/toolkit/src/tests/createReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import type {
} from '@reduxjs/toolkit'
import {
createAction,
createAsyncThunk,
createNextState,
createReducer,
isPlainObject,
} from '@reduxjs/toolkit'
import { waitMs } from './utils/helpers'

interface Todo {
text: string
Expand Down Expand Up @@ -39,6 +41,8 @@ type ToggleTodoReducer = CaseReducer<

type CreateReducer = typeof createReducer

const addTodoThunk = createAsyncThunk('todos/add', (todo: Todo) => todo)

describe('createReducer', () => {
describe('given impure reducers with immer', () => {
const addTodo: AddTodoReducer = (state, action) => {
Expand Down Expand Up @@ -341,24 +345,24 @@ describe('createReducer', () => {
expect(reducer(5, decrement(5))).toBe(0)
})
test('will throw if the same type is used twice', () => {
expect(() =>
createReducer(0, (builder) =>
expect(() => {
createReducer(0, (builder) => {
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase(increment, (state, action) => state + action.payload)
.addCase(decrement, (state, action) => state - action.payload),
),
).toThrowErrorMatchingInlineSnapshot(
.addCase(decrement, (state, action) => state - action.payload)
})
}).toThrowErrorMatchingInlineSnapshot(
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
)
expect(() =>
createReducer(0, (builder) =>
expect(() => {
createReducer(0, (builder) => {
builder
.addCase(increment, (state, action) => state + action.payload)
.addCase('increment', (state) => state + 1)
.addCase(decrement, (state, action) => state - action.payload),
),
).toThrowErrorMatchingInlineSnapshot(
.addCase(decrement, (state, action) => state - action.payload)
})
}).toThrowErrorMatchingInlineSnapshot(
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
)
})
Expand All @@ -369,14 +373,14 @@ describe('createReducer', () => {
payload,
})
customActionCreator.type = ''
expect(() =>
createReducer(0, (builder) =>
expect(() => {
createReducer(0, (builder) => {
builder.addCase(
customActionCreator,
(state, action) => state + action.payload,
),
),
).toThrowErrorMatchingInlineSnapshot(
)
})
}).toThrowErrorMatchingInlineSnapshot(
`[Error: \`builder.addCase\` cannot be called with an empty action type]`,
)
})
Expand Down Expand Up @@ -529,6 +533,56 @@ describe('createReducer', () => {
)
})
})
describe('builder "addAsyncThunk" method', () => {
const initialState = { todos: [] as Todo[], loading: false, errored: false }
test('uses the matching reducer for each action type', () => {
const reducer = createReducer(initialState, (builder) =>
builder.addAsyncThunk(addTodoThunk, {
pending(state) {
state.loading = true
},
fulfilled(state, action) {
state.todos.push(action.payload)
},
rejected(state) {
state.errored = true
},
settled(state) {
state.loading = false
},
}),
)
const todo: Todo = { text: 'test' }
expect(reducer(undefined, addTodoThunk.pending('test', todo))).toEqual({
todos: [],
loading: true,
errored: false,
})
expect(
reducer(undefined, addTodoThunk.fulfilled(todo, 'test', todo)),
).toEqual({
todos: [todo],
loading: false,
errored: false,
})
expect(
reducer(undefined, addTodoThunk.rejected(new Error(), 'test', todo)),
).toEqual({
todos: [],
loading: false,
errored: true,
})
})
test('calling addAsyncThunk after addDefaultCase should result in an error in development mode', () => {
expect(() =>
createReducer(initialState, (builder: any) =>
builder.addDefaultCase(() => {}).addAsyncThunk(addTodoThunk, {}),
),
).toThrowErrorMatchingInlineSnapshot(
`[Error: \`builder.addAsyncThunk\` should only be called before calling \`builder.addDefaultCase\`]`,
)
})
})
})

function behavesLikeReducer(todosReducer: TodosReducer) {
Expand Down
53 changes: 53 additions & 0 deletions packages/toolkit/src/tests/createSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
combineSlices,
configureStore,
createAction,
createAsyncThunk,
createSlice,
} from '@reduxjs/toolkit'

Expand Down Expand Up @@ -265,6 +266,58 @@ describe('createSlice', () => {
)
})

test('can be used with addAsyncThunk and async thunks', () => {
const asyncThunk = createAsyncThunk('test', (n: number) => n)
const slice = createSlice({
name: 'counter',
initialState: {
loading: false,
errored: false,
value: 0,
},
reducers: {},
extraReducers: (builder) =>
builder.addAsyncThunk(asyncThunk, {
pending(state) {
state.loading = true
},
fulfilled(state, action) {
state.value = action.payload
},
rejected(state) {
state.errored = true
},
settled(state) {
state.loading = false
},
}),
})
expect(
slice.reducer(undefined, asyncThunk.pending('requestId', 5)),
).toEqual({
loading: true,
errored: false,
value: 0,
})
expect(
slice.reducer(undefined, asyncThunk.fulfilled(5, 'requestId', 5)),
).toEqual({
loading: false,
errored: false,
value: 5,
})
expect(
slice.reducer(
undefined,
asyncThunk.rejected(new Error(), 'requestId', 5),
),
).toEqual({
loading: false,
errored: true,
value: 0,
})
})

test('can be used with addMatcher and type guard functions', () => {
const slice = createSlice({
name: 'counter',
Expand Down
Loading
Loading