diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 226d6cb8c8..bb7a64c8c3 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -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' @@ -300,25 +304,7 @@ type AsyncThunkSliceReducerConfig< ThunkArg extends any, Returned = unknown, ThunkApiConfig extends AsyncThunkConfig = {}, -> = { - pending?: CaseReducer< - State, - ReturnType['pending']> - > - rejected?: CaseReducer< - State, - ReturnType['rejected']> - > - fulfilled?: CaseReducer< - State, - ReturnType['fulfilled']> - > - settled?: CaseReducer< - State, - ReturnType< - AsyncThunk['rejected' | 'fulfilled'] - > - > +> = AsyncThunkReducers & { options?: AsyncThunkOptions } diff --git a/packages/toolkit/src/mapBuilders.ts b/packages/toolkit/src/mapBuilders.ts index 31ba5b4142..b071f66b0c 100644 --- a/packages/toolkit/src/mapBuilders.ts +++ b/packages/toolkit/src/mapBuilders.ts @@ -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['pending']> + > + rejected?: CaseReducer< + State, + ReturnType['rejected']> + > + fulfilled?: CaseReducer< + State, + ReturnType['fulfilled']> + > + settled?: CaseReducer< + State, + ReturnType< + AsyncThunk['rejected' | 'fulfilled'] + > + > +} export type TypedActionCreator = { (...args: any[]): Action @@ -31,7 +58,7 @@ export interface ActionReducerMapBuilder { /** * 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. */ @@ -40,12 +67,28 @@ export interface ActionReducerMapBuilder { reducer: CaseReducer, ): ActionReducerMapBuilder + /** + * 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, + reducers: AsyncThunkReducers, + ): Omit, '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. @@ -99,7 +142,7 @@ const reducer = createReducer(initialState, (builder) => { addMatcher( matcher: TypeGuard | ((action: any) => boolean), reducer: CaseReducer, - ): Omit, 'addCase'> + ): Omit, 'addCase' | 'addAsyncThunk'> /** * Adds a "default case" reducer that is executed if no case reducer and no matcher @@ -173,6 +216,35 @@ export function executeReducerBuilderCallback( actionsMap[type] = reducer return builder }, + addAsyncThunk< + Returned, + ThunkArg, + ThunkApiConfig extends AsyncThunkConfig = {}, + >( + asyncThunk: AsyncThunk, + reducers: AsyncThunkReducers, + ) { + 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( matcher: TypeGuard, reducer: CaseReducer, diff --git a/packages/toolkit/src/tests/createReducer.test.ts b/packages/toolkit/src/tests/createReducer.test.ts index e57bd47da9..114096c069 100644 --- a/packages/toolkit/src/tests/createReducer.test.ts +++ b/packages/toolkit/src/tests/createReducer.test.ts @@ -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 @@ -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) => { @@ -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']`, ) }) @@ -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]`, ) }) @@ -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) { diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index 248e7c71fe..09f726c113 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -6,6 +6,7 @@ import { combineSlices, configureStore, createAction, + createAsyncThunk, createSlice, } from '@reduxjs/toolkit' @@ -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', diff --git a/packages/toolkit/src/tests/mapBuilders.test-d.ts b/packages/toolkit/src/tests/mapBuilders.test-d.ts index 63aece1c18..d2ec028ccf 100644 --- a/packages/toolkit/src/tests/mapBuilders.test-d.ts +++ b/packages/toolkit/src/tests/mapBuilders.test-d.ts @@ -128,21 +128,42 @@ describe('type tests', () => { expectTypeOf(action).toMatchTypeOf() }) - test('addMatcher() should prevent further calls to addCase()', () => { + test('addAsyncThunk() should prevent further calls to addCase() ', () => { + const asyncThunk = createAsyncThunk('test', () => {}) + const b = builder.addAsyncThunk(asyncThunk, { + pending: () => {}, + rejected: () => {}, + fulfilled: () => {}, + settled: () => {}, + }) + + expectTypeOf(b).not.toHaveProperty('addCase') + + expectTypeOf(b.addAsyncThunk).toBeFunction() + + expectTypeOf(b.addMatcher).toBeCallableWith(increment.match, () => {}) + + expectTypeOf(b.addDefaultCase).toBeCallableWith(() => {}) + }) + + test('addMatcher() should prevent further calls to addCase() and addAsyncThunk()', () => { const b = builder.addMatcher(increment.match, () => {}) expectTypeOf(b).not.toHaveProperty('addCase') + expectTypeOf(b).not.toHaveProperty('addAsyncThunk') expectTypeOf(b.addMatcher).toBeCallableWith(increment.match, () => {}) expectTypeOf(b.addDefaultCase).toBeCallableWith(() => {}) }) - test('addDefaultCase() should prevent further calls to addCase(), addMatcher() and addDefaultCase', () => { + test('addDefaultCase() should prevent further calls to addCase(), addAsyncThunk(), addMatcher() and addDefaultCase', () => { const b = builder.addDefaultCase(() => {}) expectTypeOf(b).not.toHaveProperty('addCase') + expectTypeOf(b).not.toHaveProperty('addAsyncThunk') + expectTypeOf(b).not.toHaveProperty('addMatcher') expectTypeOf(b).not.toHaveProperty('addDefaultCase') @@ -188,79 +209,206 @@ describe('type tests', () => { } }>() }) + + builder.addAsyncThunk(thunk, { + pending(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: undefined + meta: { + arg: void + requestId: string + requestStatus: 'pending' + } + }>() + }, + rejected(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + } + }>() + }, + fulfilled(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + } + }>() + }, + settled(_, action) { + expectTypeOf(action).toMatchTypeOf< + | { + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + } + } + | { + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + } + } + >() + }, + }) }) - }) - test('case 2: `createAsyncThunk` with `meta`', () => { - const thunk = createAsyncThunk< - 'ret', - void, - { - pendingMeta: { startedTimeStamp: number } - fulfilledMeta: { - fulfilledTimeStamp: number - baseQueryMeta: 'meta!' - } - rejectedMeta: { - baseQueryMeta: 'meta!' + test('case 2: `createAsyncThunk` with `meta`', () => { + const thunk = createAsyncThunk< + 'ret', + void, + { + pendingMeta: { startedTimeStamp: number } + fulfilledMeta: { + fulfilledTimeStamp: number + baseQueryMeta: 'meta!' + } + rejectedMeta: { + baseQueryMeta: 'meta!' + } } - } - >( - 'test', - (_, api) => { - return api.fulfillWithValue('ret' as const, { - fulfilledTimeStamp: 5, - baseQueryMeta: 'meta!', - }) - }, - { - getPendingMeta() { - return { startedTimeStamp: 0 } + >( + 'test', + (_, api) => { + return api.fulfillWithValue('ret' as const, { + fulfilledTimeStamp: 5, + baseQueryMeta: 'meta!', + }) }, - }, - ) + { + getPendingMeta() { + return { startedTimeStamp: 0 } + }, + }, + ) - builder.addCase(thunk.pending, (_, action) => { - expectTypeOf(action).toMatchTypeOf<{ - payload: undefined - meta: { - arg: void - requestId: string - requestStatus: 'pending' - startedTimeStamp: number - } - }>() - }) + builder.addCase(thunk.pending, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: undefined + meta: { + arg: void + requestId: string + requestStatus: 'pending' + startedTimeStamp: number + } + }>() + }) - builder.addCase(thunk.rejected, (_, action) => { - expectTypeOf(action).toMatchTypeOf<{ - payload: unknown - error: SerializedError - meta: { - arg: void - requestId: string - requestStatus: 'rejected' - aborted: boolean - condition: boolean - rejectedWithValue: boolean - baseQueryMeta?: 'meta!' - } - }>() + builder.addCase(thunk.rejected, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + baseQueryMeta?: 'meta!' + } + }>() - if (action.meta.rejectedWithValue) { - expectTypeOf(action.meta.baseQueryMeta).toEqualTypeOf<'meta!'>() - } - }) - builder.addCase(thunk.fulfilled, (_, action) => { - expectTypeOf(action).toMatchTypeOf<{ - payload: 'ret' - meta: { - arg: void - requestId: string - requestStatus: 'fulfilled' - baseQueryMeta: 'meta!' + if (action.meta.rejectedWithValue) { + expectTypeOf(action.meta.baseQueryMeta).toEqualTypeOf<'meta!'>() } - }>() + }) + builder.addCase(thunk.fulfilled, (_, action) => { + expectTypeOf(action).toMatchTypeOf<{ + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + baseQueryMeta: 'meta!' + } + }>() + }) + + builder.addAsyncThunk(thunk, { + pending(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: undefined + meta: { + arg: void + requestId: string + requestStatus: 'pending' + startedTimeStamp: number + } + }>() + }, + rejected(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + baseQueryMeta?: 'meta!' + } + }>() + }, + fulfilled(_, action) { + expectTypeOf(action).toMatchTypeOf<{ + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + baseQueryMeta: 'meta!' + } + }>() + }, + settled(_, action) { + expectTypeOf(action).toMatchTypeOf< + | { + payload: 'ret' + meta: { + arg: void + requestId: string + requestStatus: 'fulfilled' + baseQueryMeta: 'meta!' + } + } + | { + payload: unknown + error: SerializedError + meta: { + arg: void + requestId: string + requestStatus: 'rejected' + aborted: boolean + condition: boolean + rejectedWithValue: boolean + baseQueryMeta?: 'meta!' + } + } + >() + }, + }) }) }) })