diff --git a/docs/api/createSlice.mdx b/docs/api/createSlice.mdx index 39f10f8b70..2c3d0c0cee 100644 --- a/docs/api/createSlice.mdx +++ b/docs/api/createSlice.mdx @@ -130,6 +130,192 @@ const todosSlice = createSlice({ }) ``` +### The `reducers` "creator callback" notation + +Alternatively, the `reducers` field can be a callback which receives a "create" object. + +The main benefit of this is that you can create [async thunks](./createAsyncThunk) as part of your slice. Types are also slightly simplified for prepared reducers. + +```ts title="Creator callback for reducers" +import { createSlice, nanoid } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' + +interface Item { + id: string + text: string +} + +interface TodoState { + loading: boolean + todos: Item[] +} + +const todosSlice = createSlice({ + name: 'todos', + initialState: { + loading: false, + todos: [], + } as TodoState, + reducers: (create) => ({ + deleteTodo: create.reducer((state, action: PayloadAction) => { + state.todos.splice(action.payload, 1) + }), + addTodo: create.preparedReducer( + (text: string) => { + const id = nanoid() + return { payload: { id, text } } + }, + // action type is inferred from prepare callback + (state, action) => { + state.todos.push(action.payload) + } + ), + fetchTodo: create.asyncThunk( + async (id: string, thunkApi) => { + const res = await fetch(`myApi/todos?id=${id}`) + return (await res.json()) as Item + }, + { + pending: (state) => { + state.loading = true + }, + rejected: (state, action) => { + state.loading = false + }, + fulfilled: (state, action) => { + state.loading = false + state.todos.push(action.payload) + }, + } + ), + }), +}) + +export const { addTodo, deleteTodo, fetchTodo } = todosSlice.actions +``` + +#### Create Methods + +#### `create.reducer` + +A standard slice case reducer. + +**Parameters** + +- **reducer** The slice case reducer to use. + +```ts no-transpile +create.reducer((state, action: PayloadAction) => { + state.todos.push(action.payload) +}) +``` + +#### `create.preparedReducer` + +A [prepared](#customizing-generated-action-creators) reducer, to customize the action creator. + +**Parameters** + +- **prepareAction** The [`prepare callback`](./createAction#using-prepare-callbacks-to-customize-action-contents). +- **reducer** The slice case reducer to use. + +The action passed to the case reducer will be inferred from the prepare callback's return. + +```ts no-transpile +create.preparedReducer( + (text: string) => { + const id = nanoid() + return { payload: { id, text } } + }, + (state, action) => { + state.todos.push(action.payload) + } +) +``` + +#### `create.asyncThunk` + +Creates an async thunk instead of an action creator. + +**Parameters** + +- **payloadCreator** The thunk [payload creator](./createAsyncThunk#payloadcreator). +- **config** The configuration object. (optional) + +The configuration object can contain case reducers for each of the [lifecycle actions](./createAsyncThunk#promise-lifecycle-actions) (`pending`, `fulfilled`, and `rejected`). + +Each case reducer will be attached to the slice's `caseReducers` object, e.g. `slice.caseReducers.fetchTodo.fulfilled`. + +The configuration object can also contain [`options`](./createAsyncThunk#options). + +```ts no-transpile +create.asyncThunk( + async (id: string, thunkApi) => { + const res = await fetch(`myApi/todos?id=${id}`) + return (await res.json()) as Item + }, + { + pending: (state) => { + state.loading = true + }, + rejected: (state, action) => { + state.loading = false + }, + fulfilled: (state, action) => { + state.loading = false + state.todos.push(action.payload) + }, + options: { + idGenerator: uuid, + }, + } +) +``` + +:::note + +Typing for the `create.asyncThunk` works in the same way as [`createAsyncThunk`](usage/usage-with-typescript#createasyncthunk), with one key difference. + +A type for `state` and/or `dispatch` _cannot_ be provided as part of the `ThunkApiConfig`, as this would cause circular types. + +Instead, it is necessary to assert the type when needed. + +```ts no-transpile +create.asyncThunk( + async (id, thunkApi) => { + const state = thunkApi.getState() as RootState + const dispatch = thunkApi.dispatch as AppDispatch + throw thunkApi.rejectWithValue({ + error: 'Oh no!', + }) + } +) +``` + +For common thunk API configuration options, a [`withTypes` helper](usage/usage-with-typescript#defining-a-pre-typed-createasyncthunk) is provided: + +```ts no-transpile +reducers: (create) => { + const createAThunk = + create.asyncThunk.withTypes<{ rejectValue: { error: string } }>() + + return { + fetchTodo: createAThunk(async (id, thunkApi) => { + throw thunkApi.rejectWithValue({ + error: 'Oh no!', + }) + }), + fetchTodos: createAThunk(async (id, thunkApi) => { + throw thunkApi.rejectWithValue({ + error: 'Oh no, not again!', + }) + }), + } +} +``` + +::: + ### `extraReducers` One of the key concepts of Redux is that each slice reducer "owns" its slice of state, and that many slice reducers diff --git a/docs/api/getDefaultMiddleware.mdx b/docs/api/getDefaultMiddleware.mdx index a574d445f2..2b6948ba01 100644 --- a/docs/api/getDefaultMiddleware.mdx +++ b/docs/api/getDefaultMiddleware.mdx @@ -40,14 +40,7 @@ to the store. `configureStore` will not add any extra middleware beyond what you `getDefaultMiddleware` is useful if you want to add some custom middleware, but also still want to have the default middleware added as well: -```ts -// file: reducer.ts noEmit - -export default function rootReducer(state = {}, action: any) { - return state -} - -// file: store.ts +```ts no-transpile import { configureStore } from '@reduxjs/toolkit' import logger from 'redux-logger' diff --git a/docs/rtk-query/usage/error-handling.mdx b/docs/rtk-query/usage/error-handling.mdx index 40ad27b2ad..bfecee97c9 100644 --- a/docs/rtk-query/usage/error-handling.mdx +++ b/docs/rtk-query/usage/error-handling.mdx @@ -80,7 +80,7 @@ Redux Toolkit has [action matching utilities](../../api/matching-utilities.mdx#m ::: -```ts title="Error catching middleware example" +```ts no-transpile title="Error catching middleware example" import { isRejectedWithValue } from '@reduxjs/toolkit' import type { MiddlewareAPI, Middleware } from '@reduxjs/toolkit' import { toast } from 'your-cool-library' diff --git a/packages/toolkit/src/createAsyncThunk.ts b/packages/toolkit/src/createAsyncThunk.ts index 5a805ebc3a..7a8cac4e24 100644 --- a/packages/toolkit/src/createAsyncThunk.ts +++ b/packages/toolkit/src/createAsyncThunk.ts @@ -105,7 +105,7 @@ export const miniSerializeError = (value: any): SerializedError => { return { message: String(value) } } -type AsyncThunkConfig = { +export type AsyncThunkConfig = { state?: unknown dispatch?: Dispatch extra?: unknown @@ -414,7 +414,7 @@ export type AsyncThunk< typePrefix: string } -type OverrideThunkApiConfigs = Id< +export type OverrideThunkApiConfigs = Id< NewConfig & Omit > diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 07cca7b6c7..cb69c3b0ed 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -1,4 +1,4 @@ -import type { AnyAction, Reducer } from 'redux' +import type { Action, AnyAction, Reducer } from 'redux' import type { ActionCreatorWithoutPayload, PayloadAction, @@ -18,6 +18,14 @@ import { executeReducerBuilderCallback } from './mapBuilders' import type { Id, NoInfer, Tail } from './tsHelpers' import { freezeDraftable } from './utils' import type { CombinedSliceReducer, InjectConfig } from './combineSlices' +import type { + AsyncThunk, + AsyncThunkConfig, + AsyncThunkOptions, + AsyncThunkPayloadCreator, + OverrideThunkApiConfigs, +} from './createAsyncThunk' +import { createAsyncThunk } from './createAsyncThunk' /** * The return value of `createSlice` @@ -167,7 +175,9 @@ export interface CreateSliceOptions< * functions. For every action type, a matching action creator will be * generated using `createAction()`. */ - reducers: ValidateSliceCaseReducers + reducers: + | ValidateSliceCaseReducers + | ((creators: ReducerCreators) => CR) /** * A callback that receives a *builder* object to define @@ -211,7 +221,7 @@ createSlice({ }) ``` */ - extraReducers?: (builder: ActionReducerMapBuilder>) => void + extraReducers?: (builder: ActionReducerMapBuilder) => void /** * A map of selectors that receive the slice's state and any additional arguments, and return a result. @@ -219,6 +229,21 @@ createSlice({ selectors?: Selectors } +const reducerDefinitionType: unique symbol = Symbol.for('rtk-reducer-type') +enum ReducerType { + reducer = 'reducer', + reducerWithPrepare = 'reducerWithPrepare', + asyncThunk = 'asyncThunk', +} + +interface ReducerDefinition { + [reducerDefinitionType]: T +} + +export interface CaseReducerDefinition + extends CaseReducer, + ReducerDefinition {} + /** * A CaseReducer with a `prepare` method. * @@ -229,16 +254,147 @@ export type CaseReducerWithPrepare = { prepare: PrepareAction } +export interface CaseReducerWithPrepareDefinition< + State, + Action extends PayloadAction +> extends CaseReducerWithPrepare, + ReducerDefinition {} + +export interface AsyncThunkSliceReducerConfig< + State, + ThunkArg extends any, + Returned = unknown, + ThunkApiConfig extends AsyncThunkConfig = {} +> { + pending?: CaseReducer< + State, + ReturnType['pending']> + > + rejected?: CaseReducer< + State, + ReturnType['rejected']> + > + fulfilled?: CaseReducer< + State, + ReturnType['fulfilled']> + > + options?: AsyncThunkOptions +} + +export interface AsyncThunkSliceReducerDefinition< + State, + ThunkArg extends any, + Returned = unknown, + ThunkApiConfig extends AsyncThunkConfig = {} +> extends AsyncThunkSliceReducerConfig< + State, + ThunkArg, + Returned, + ThunkApiConfig + >, + ReducerDefinition { + payloadCreator: AsyncThunkPayloadCreator +} + +/** + * Providing these as part of the config would cause circular types, so we disallow passing them + */ +type PreventCircular = { + [K in keyof ThunkApiConfig]: K extends 'state' | 'dispatch' + ? never + : ThunkApiConfig[K] +} + +interface AsyncThunkCreator< + State, + CurriedThunkApiConfig extends PreventCircular = PreventCircular +> { + ( + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + CurriedThunkApiConfig + >, + config?: AsyncThunkSliceReducerConfig< + State, + ThunkArg, + Returned, + CurriedThunkApiConfig + > + ): AsyncThunkSliceReducerDefinition< + State, + ThunkArg, + Returned, + CurriedThunkApiConfig + > + < + ThunkArg extends any, + Returned = unknown, + ThunkApiConfig extends PreventCircular = {} + >( + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + ThunkApiConfig + >, + config?: AsyncThunkSliceReducerConfig< + State, + ThunkArg, + Returned, + ThunkApiConfig + > + ): AsyncThunkSliceReducerDefinition + withTypes< + ThunkApiConfig extends PreventCircular + >(): AsyncThunkCreator< + State, + OverrideThunkApiConfigs + > +} + +export interface ReducerCreators { + reducer( + caseReducer: CaseReducer> + ): CaseReducerDefinition> + + asyncThunk: AsyncThunkCreator + + preparedReducer>( + prepare: Prepare, + reducer: CaseReducer< + State, + ReturnType<_ActionCreatorWithPreparedPayload> + > + ): { + [reducerDefinitionType]: ReducerType.reducerWithPrepare + prepare: Prepare + reducer: CaseReducer< + State, + ReturnType<_ActionCreatorWithPreparedPayload> + > + } +} + /** * The type describing a slice's `reducers` option. * * @public */ -export type SliceCaseReducers = { - [K: string]: - | CaseReducer> - | CaseReducerWithPrepare> -} +export type SliceCaseReducers = + | Record< + string, + | CaseReducerDefinition> + | CaseReducerWithPrepareDefinition< + State, + PayloadAction + > + | AsyncThunkSliceReducerDefinition + > + | Record< + string, + | CaseReducer> + | CaseReducerWithPrepare> + > /** * The type describing a slice's `selectors` option. @@ -261,15 +417,29 @@ export type CaseReducerActions< CaseReducers extends SliceCaseReducers, SliceName extends string > = { - [Type in keyof CaseReducers]: CaseReducers[Type] extends { prepare: any } - ? ActionCreatorForCaseReducerWithPrepare< - CaseReducers[Type], - SliceActionType - > - : ActionCreatorForCaseReducer< - CaseReducers[Type], - SliceActionType - > + [Type in keyof CaseReducers]: CaseReducers[Type] extends infer Definition + ? Definition extends { prepare: any } + ? ActionCreatorForCaseReducerWithPrepare< + Definition, + SliceActionType + > + : Definition extends AsyncThunkSliceReducerDefinition< + any, + infer ThunkArg, + infer Returned, + infer ThunkApiConfig + > + ? AsyncThunk + : Definition extends { reducer: any } + ? ActionCreatorForCaseReducer< + Definition['reducer'], + SliceActionType + > + : ActionCreatorForCaseReducer< + Definition, + SliceActionType + > + : never } /** @@ -303,11 +473,15 @@ type ActionCreatorForCaseReducer = CR extends ( * @internal */ type SliceDefinedCaseReducers> = { - [Type in keyof CaseReducers]: CaseReducers[Type] extends { - reducer: infer Reducer - } - ? Reducer - : CaseReducers[Type] + [Type in keyof CaseReducers]: CaseReducers[Type] extends infer Definition + ? Definition extends AsyncThunkSliceReducerDefinition + ? Id, 'fulfilled' | 'rejected' | 'pending'>> + : Definition extends { + reducer: infer Reducer + } + ? Reducer + : Definition + : never } /** @@ -362,8 +536,6 @@ function getType(slice: string, actionKey: string): string { * action creators and action types that correspond to the * reducers and state. * - * The `reducer` argument is passed to `createReducer()`. - * * @public */ export function createSlice< @@ -391,38 +563,39 @@ export function createSlice< } } - const initialState = - typeof options.initialState == 'function' - ? options.initialState - : freezeDraftable(options.initialState) - - const reducers = options.reducers || {} + const reducers = + (typeof options.reducers === 'function' + ? options.reducers(buildReducerCreators()) + : options.reducers) || {} const reducerNames = Object.keys(reducers) - const sliceCaseReducersByName: Record = {} - const sliceCaseReducersByType: Record = {} - const actionCreators: Record = {} + const context: ReducerHandlingContext = { + sliceCaseReducersByName: {}, + sliceCaseReducersByType: {}, + actionCreators: {}, + } reducerNames.forEach((reducerName) => { - const maybeReducerWithPrepare = reducers[reducerName] - const type = getType(name, reducerName) - - let caseReducer: CaseReducer - let prepareCallback: PrepareAction | undefined - - if ('reducer' in maybeReducerWithPrepare) { - caseReducer = maybeReducerWithPrepare.reducer - prepareCallback = maybeReducerWithPrepare.prepare + const reducerDefinition = reducers[reducerName] + const reducerDetails: ReducerDetails = { + reducerName, + type: getType(name, reducerName), + createNotation: typeof options.reducers === 'function', + } + if (isAsyncThunkSliceReducerDefinition(reducerDefinition)) { + handleThunkCaseReducerDefinition( + reducerDetails, + reducerDefinition, + context + ) } else { - caseReducer = maybeReducerWithPrepare + handleNormalReducerDefinition( + reducerDetails, + reducerDefinition, + context + ) } - - sliceCaseReducersByName[reducerName] = caseReducer - sliceCaseReducersByType[type] = caseReducer - actionCreators[reducerName] = prepareCallback - ? createAction(type, prepareCallback) - : createAction(type) }) function buildReducer() { @@ -442,9 +615,12 @@ export function createSlice< ? executeReducerBuilderCallback(options.extraReducers) : [options.extraReducers] - const finalCaseReducers = { ...extraReducers, ...sliceCaseReducersByType } + const finalCaseReducers = { + ...extraReducers, + ...context.sliceCaseReducersByType, + } - return createReducer(initialState, (builder) => { + return createReducer(options.initialState, (builder) => { for (let key in finalCaseReducers) { builder.addCase(key, finalCaseReducers[key] as CaseReducer) } @@ -481,8 +657,8 @@ export function createSlice< return _reducer(state, action) }, - actions: actionCreators as any, - caseReducers: sliceCaseReducersByName as any, + actions: context.actionCreators as any, + caseReducers: context.sliceCaseReducersByName as any, getInitialState() { if (!_reducer) _reducer = buildReducer() @@ -537,3 +713,133 @@ export function createSlice< } return slice } + +interface ReducerHandlingContext { + sliceCaseReducersByName: Record< + string, + | CaseReducer + | Pick< + AsyncThunkSliceReducerDefinition, + 'fulfilled' | 'rejected' | 'pending' + > + > + sliceCaseReducersByType: Record> + actionCreators: Record +} + +interface ReducerDetails { + reducerName: string + type: string + createNotation: boolean +} + +function buildReducerCreators(): ReducerCreators { + function asyncThunk( + payloadCreator: AsyncThunkPayloadCreator, + config: AsyncThunkSliceReducerConfig + ): AsyncThunkSliceReducerDefinition { + return { + [reducerDefinitionType]: ReducerType.asyncThunk, + payloadCreator, + ...config, + } + } + asyncThunk.withTypes = () => asyncThunk + return { + reducer(caseReducer) { + return Object.assign( + { + // hack so the wrapping function has the same name as the original + // we need to create a wrapper so the `reducerDefinitionType` is not assigned to the original + [caseReducer.name](...args: Parameters) { + return caseReducer(...args) + }, + }[caseReducer.name], + { + [reducerDefinitionType]: ReducerType.reducer, + } as const + ) + }, + preparedReducer(prepare, reducer) { + return { + [reducerDefinitionType]: ReducerType.reducerWithPrepare, + prepare, + reducer, + } + }, + asyncThunk: asyncThunk as any, + } +} + +function handleNormalReducerDefinition( + { type, reducerName, createNotation }: ReducerDetails, + maybeReducerWithPrepare: + | CaseReducer + | CaseReducerWithPrepare>, + context: ReducerHandlingContext +) { + let caseReducer: CaseReducer + let prepareCallback: PrepareAction | undefined + if ('reducer' in maybeReducerWithPrepare) { + if ( + createNotation && + !isCaseReducerWithPrepareDefinition(maybeReducerWithPrepare) + ) { + throw new Error( + 'Please use the `create.preparedReducer` notation for prepared action creators with the `create` notation.' + ) + } + caseReducer = maybeReducerWithPrepare.reducer + prepareCallback = maybeReducerWithPrepare.prepare + } else { + caseReducer = maybeReducerWithPrepare + } + context.sliceCaseReducersByName[reducerName] = caseReducer + context.sliceCaseReducersByType[type] = caseReducer + context.actionCreators[reducerName] = prepareCallback + ? createAction(type, prepareCallback) + : createAction(type) +} + +function isAsyncThunkSliceReducerDefinition( + reducerDefinition: any +): reducerDefinition is AsyncThunkSliceReducerDefinition { + return reducerDefinition[reducerDefinitionType] === ReducerType.asyncThunk +} + +function isCaseReducerWithPrepareDefinition( + reducerDefinition: any +): reducerDefinition is CaseReducerWithPrepareDefinition { + return ( + reducerDefinition[reducerDefinitionType] === ReducerType.reducerWithPrepare + ) +} + +function handleThunkCaseReducerDefinition( + { type, reducerName }: ReducerDetails, + reducerDefinition: AsyncThunkSliceReducerDefinition, + context: ReducerHandlingContext +) { + const { payloadCreator, fulfilled, pending, rejected, options } = + reducerDefinition + const thunk = createAsyncThunk(type, payloadCreator, options as any) + context.actionCreators[reducerName] = thunk + + if (fulfilled) { + context.sliceCaseReducersByType[thunk.fulfilled.type] = fulfilled + } + if (pending) { + context.sliceCaseReducersByType[thunk.pending.type] = pending + } + if (rejected) { + context.sliceCaseReducersByType[thunk.rejected.type] = rejected + } + + context.sliceCaseReducersByName[reducerName] = { + fulfilled: fulfilled || noop, + pending: pending || noop, + rejected: rejected || noop, + } +} + +function noop() {} diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 5210b24743..977cc4cce5 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -69,6 +69,7 @@ export type { SliceCaseReducers, ValidateSliceCaseReducers, CaseReducerWithPrepare, + ReducerCreators, } from './createSlice' export { // js diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index a92893180b..9a343bd2fb 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -1,7 +1,11 @@ import { vi } from 'vitest' import type { PayloadAction, WithSlice } from '@reduxjs/toolkit' -import { combineSlices } from '@reduxjs/toolkit' -import { createSlice, createAction } from '@reduxjs/toolkit' +import { + configureStore, + combineSlices, + createSlice, + createAction, +} from '@reduxjs/toolkit' import { mockConsole, createConsole, @@ -553,4 +557,215 @@ describe('createSlice', () => { expect(injected2State.injected2).toBe(slice.getInitialState() + 1) }) }) + describe('reducers definition with asyncThunks', () => { + function pending(state: any[], action: any) { + state.push(['pendingReducer', action]) + } + function fulfilled(state: any[], action: any) { + state.push(['fulfilledReducer', action]) + } + function rejected(state: any[], action: any) { + state.push(['rejectedReducer', action]) + } + + test('successful thunk', async () => { + const slice = createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + function payloadCreator(arg, api) { + return Promise.resolve('resolved payload') + }, + { pending, fulfilled, rejected } + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers('test')) + expect(store.getState()).toMatchObject([ + [ + 'pendingReducer', + { + type: 'test/thunkReducers/pending', + payload: undefined, + }, + ], + [ + 'fulfilledReducer', + { + type: 'test/thunkReducers/fulfilled', + payload: 'resolved payload', + }, + ], + ]) + }) + + test('rejected thunk', async () => { + const slice = createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + // payloadCreator isn't allowed to return never + function payloadCreator(arg, api): any { + throw new Error('') + }, + { pending, fulfilled, rejected } + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers('test')) + expect(store.getState()).toMatchObject([ + [ + 'pendingReducer', + { + type: 'test/thunkReducers/pending', + payload: undefined, + }, + ], + [ + 'rejectedReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + }, + ], + ]) + }) + + test('with options', async () => { + const slice = createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + function payloadCreator(arg, api) { + return 'should not call this' + }, + { + options: { + condition() { + return false + }, + dispatchConditionRejection: true, + }, + pending, + fulfilled, + rejected, + } + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers('test')) + expect(store.getState()).toMatchObject([ + [ + 'rejectedReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + meta: { condition: true }, + }, + ], + ]) + }) + + test('has caseReducers for the asyncThunk', async () => { + const slice = createSlice({ + name: 'test', + initialState: [], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + function payloadCreator(arg, api) { + return Promise.resolve('resolved payload') + }, + { pending, fulfilled } + ), + }), + }) + + expect(slice.caseReducers.thunkReducers.pending).toBe(pending) + expect(slice.caseReducers.thunkReducers.fulfilled).toBe(fulfilled) + // even though it is not defined above, this should at least be a no-op function to match the TypeScript typings + // and should be callable as a reducer even if it does nothing + expect(() => + slice.caseReducers.thunkReducers.rejected( + [], + slice.actions.thunkReducers.rejected( + new Error('test'), + 'fakeRequestId', + {} + ) + ) + ).not.toThrow() + }) + + test('can define reducer with prepare statement using create.preparedReducer', async () => { + const slice = createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + prepared: create.preparedReducer( + (p: string, m: number, e: { message: string }) => ({ + payload: p, + meta: m, + error: e, + }), + (state, action) => { + state.push(action) + } + ), + }), + }) + + expect( + slice.reducer([], slice.actions.prepared('test', 1, { message: 'err' })) + ).toMatchInlineSnapshot(` + [ + { + "error": { + "message": "err", + }, + "meta": 1, + "payload": "test", + "type": "test/prepared", + }, + ] + `) + }) + + test('throws an error when invoked with a normal `prepare` object that has not gone through a `create.preparedReducer` call', async () => { + expect(() => + createSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + prepared: { + prepare: (p: string, m: number, e: { message: string }) => ({ + payload: p, + meta: m, + error: e, + }), + reducer: (state, action) => { + state.push(action) + }, + }, + }), + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Please use the \`create.preparedReducer\` notation for prepared action creators with the \`create\` notation."` + ) + }) + }) }) diff --git a/packages/toolkit/src/tests/createSlice.typetest.ts b/packages/toolkit/src/tests/createSlice.typetest.ts index 11669eafee..d91fdbeb60 100644 --- a/packages/toolkit/src/tests/createSlice.typetest.ts +++ b/packages/toolkit/src/tests/createSlice.typetest.ts @@ -6,12 +6,20 @@ import type { ActionCreatorWithPayload, ActionCreatorWithPreparedPayload, ActionReducerMapBuilder, + AsyncThunk, + CaseReducer, PayloadAction, + PayloadActionCreator, + ReducerCreators, + SerializedError, SliceCaseReducers, + ThunkDispatch, ValidateSliceCaseReducers, } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' import { createAction, createSlice } from '@reduxjs/toolkit' -import { expectType } from './helpers' +import { expectExactType, expectType, expectUnknown } from './helpers' +import { castDraft } from 'immer' /* * Test: Slice name is strongly typed. @@ -552,3 +560,230 @@ const value = actionCreators.anyKey expectType(nestedSelectors.selectMultiply(nestedState, 2)) expectType(nestedSelectors.selectToFixed(nestedState)) } + +/** + * Test: reducer callback + */ + +{ + interface TestState { + foo: string + } + + interface TestArg { + test: string + } + + interface TestReturned { + payload: string + } + + interface TestReject { + cause: string + } + + const slice = createSlice({ + name: 'test', + initialState: {} as TestState, + reducers: (create) => { + const pretypedAsyncThunk = + create.asyncThunk.withTypes<{ rejectValue: TestReject }>() + + // @ts-expect-error + create.asyncThunk(() => {}) + + // @ts-expect-error + create.asyncThunk.withTypes<{ + rejectValue: string + dispatch: StoreDispatch + }>() + + return { + normalReducer: create.reducer((state, action) => { + expectType(state) + expectType(action.payload) + }), + preparedReducer: create.preparedReducer( + (payload: string) => ({ + payload, + meta: 'meta' as const, + error: 'error' as const, + }), + (state, action) => { + expectType(state) + expectType(action.payload) + expectExactType('meta' as const)(action.meta) + expectExactType('error' as const)(action.error) + } + ), + testInfer: create.asyncThunk( + function payloadCreator(arg: TestArg, api) { + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectType(state) + expectType(action.meta.arg) + }, + fulfilled(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.payload) + }, + rejected(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.error) + }, + } + ), + testExplicitType: create.asyncThunk< + TestArg, + TestReturned, + { + rejectValue: TestReject + } + >( + function payloadCreator(arg, api) { + // here would be a circular reference + expectUnknown(api.getState()) + // here would be a circular reference + expectType>(api.dispatch) + // so you need to cast inside instead + const getState = api.getState as () => StoreState + const dispatch = api.dispatch as StoreDispatch + expectType(arg) + expectType<(value: TestReject) => any>(api.rejectWithValue) + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectType(state) + expectType(action.meta.arg) + }, + fulfilled(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.payload) + }, + rejected(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.error) + expectType(action.payload) + }, + } + ), + testPretyped: pretypedAsyncThunk( + function payloadCreator(arg: TestArg, api) { + expectType<(value: TestReject) => any>(api.rejectWithValue) + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectType(state) + expectType(action.meta.arg) + }, + fulfilled(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.payload) + }, + rejected(state, action) { + expectType(state) + expectType(action.meta.arg) + expectType(action.error) + expectType(action.payload) + }, + } + ), + } + }, + }) + + const store = configureStore({ reducer: { test: slice.reducer } }) + + type StoreState = ReturnType + type StoreDispatch = typeof store.dispatch + + expectType>(slice.actions.normalReducer) + expectType< + ActionCreatorWithPreparedPayload< + [string], + string, + 'test/preparedReducer', + 'error', + 'meta' + > + >(slice.actions.preparedReducer) + expectType>(slice.actions.testInfer) + expectType>( + slice.actions.testExplicitType + ) + { + type TestInferThunk = AsyncThunk + expectType>>( + slice.caseReducers.testInfer.pending + ) + expectType>>( + slice.caseReducers.testInfer.fulfilled + ) + expectType>>( + slice.caseReducers.testInfer.rejected + ) + } +} + +/** Test: wrapping createSlice should be possible, with callback */ +{ + interface GenericState { + data?: T + status: 'loading' | 'finished' | 'error' + } + + const createGenericSlice = < + T, + Reducers extends SliceCaseReducers> + >({ + name = '', + initialState, + reducers, + }: { + name: string + initialState: GenericState + reducers: (create: ReducerCreators>) => Reducers + }) => { + return createSlice({ + name, + initialState, + reducers: (create) => ({ + start: create.reducer((state) => { + state.status = 'loading' + }), + success: create.reducer((state, action: PayloadAction) => { + state.data = castDraft(action.payload) + state.status = 'finished' + }), + ...reducers(create), + }), + }) + } + + const wrappedSlice = createGenericSlice({ + name: 'test', + initialState: { status: 'loading' } as GenericState, + reducers: (create) => ({ + magic: create.reducer((state) => { + expectType>(state) + // @ts-expect-error + expectType>(state) + + state.status = 'finished' + state.data = 'hocus pocus' + }), + }), + }) + + expectType>(wrappedSlice.actions.success) + expectType>(wrappedSlice.actions.magic) +}