300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import type { Action } from 'redux'
|
|
import type {
|
|
CaseReducer,
|
|
CaseReducers,
|
|
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>
|
|
type: Type
|
|
}
|
|
|
|
/**
|
|
* A builder for an action <-> reducer map.
|
|
*
|
|
* @public
|
|
*/
|
|
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`.
|
|
* @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.
|
|
*/
|
|
addCase<ActionCreator extends TypedActionCreator<string>>(
|
|
actionCreator: ActionCreator,
|
|
reducer: CaseReducer<State, ReturnType<ActionCreator>>,
|
|
): 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.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.
|
|
*/
|
|
addCase<Type extends string, A extends Action<Type>>(
|
|
type: Type,
|
|
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.
|
|
* @example
|
|
```ts no-transpile
|
|
import { createAsyncThunk, createReducer } from '@reduxjs/toolkit'
|
|
|
|
const fetchUserById = createAsyncThunk('users/fetchUser', async (id) => {
|
|
const response = await fetch(`https://reqres.in/api/users/${id}`)
|
|
return (await response.json()).data
|
|
})
|
|
|
|
const reducer = createReducer(initialState, (builder) => {
|
|
builder.addAsyncThunk(fetchUserById, {
|
|
pending: (state, action) => {
|
|
state.fetchUserById.loading = 'pending'
|
|
},
|
|
fulfilled: (state, action) => {
|
|
state.fetchUserById.data = action.payload
|
|
},
|
|
rejected: (state, action) => {
|
|
state.fetchUserById.error = action.error
|
|
},
|
|
settled: (state, action) => {
|
|
state.fetchUserById.loading = action.meta.requestStatus
|
|
},
|
|
})
|
|
})
|
|
*/
|
|
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 `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.
|
|
*
|
|
* @example
|
|
```ts
|
|
import {
|
|
createAction,
|
|
createReducer,
|
|
AsyncThunk,
|
|
UnknownAction,
|
|
} from "@reduxjs/toolkit";
|
|
|
|
type GenericAsyncThunk = AsyncThunk<unknown, unknown, any>;
|
|
|
|
type PendingAction = ReturnType<GenericAsyncThunk["pending"]>;
|
|
type RejectedAction = ReturnType<GenericAsyncThunk["rejected"]>;
|
|
type FulfilledAction = ReturnType<GenericAsyncThunk["fulfilled"]>;
|
|
|
|
const initialState: Record<string, string> = {};
|
|
const resetAction = createAction("reset-tracked-loading-state");
|
|
|
|
function isPendingAction(action: UnknownAction): action is PendingAction {
|
|
return typeof action.type === "string" && action.type.endsWith("/pending");
|
|
}
|
|
|
|
const reducer = createReducer(initialState, (builder) => {
|
|
builder
|
|
.addCase(resetAction, () => initialState)
|
|
// matcher can be defined outside as a type predicate function
|
|
.addMatcher(isPendingAction, (state, action) => {
|
|
state[action.meta.requestId] = "pending";
|
|
})
|
|
.addMatcher(
|
|
// matcher can be defined inline as a type predicate function
|
|
(action): action is RejectedAction => action.type.endsWith("/rejected"),
|
|
(state, action) => {
|
|
state[action.meta.requestId] = "rejected";
|
|
}
|
|
)
|
|
// matcher can just return boolean and the matcher can receive a generic argument
|
|
.addMatcher<FulfilledAction>(
|
|
(action) => action.type.endsWith("/fulfilled"),
|
|
(state, action) => {
|
|
state[action.meta.requestId] = "fulfilled";
|
|
}
|
|
);
|
|
});
|
|
```
|
|
*/
|
|
addMatcher<A>(
|
|
matcher: TypeGuard<A> | ((action: any) => boolean),
|
|
reducer: CaseReducer<State, A extends Action ? A : A & Action>,
|
|
): Omit<ActionReducerMapBuilder<State>, 'addCase' | 'addAsyncThunk'>
|
|
|
|
/**
|
|
* Adds a "default case" reducer that is executed if no case reducer and no matcher
|
|
* reducer was executed for this action.
|
|
* @param reducer - The fallback "default case" reducer function.
|
|
*
|
|
* @example
|
|
```ts
|
|
import { createReducer } from '@reduxjs/toolkit'
|
|
const initialState = { otherActions: 0 }
|
|
const reducer = createReducer(initialState, builder => {
|
|
builder
|
|
// .addCase(...)
|
|
// .addMatcher(...)
|
|
.addDefaultCase((state, action) => {
|
|
state.otherActions++
|
|
})
|
|
})
|
|
```
|
|
*/
|
|
addDefaultCase(reducer: CaseReducer<State, Action>): {}
|
|
}
|
|
|
|
export function executeReducerBuilderCallback<S>(
|
|
builderCallback: (builder: ActionReducerMapBuilder<S>) => void,
|
|
): [
|
|
CaseReducers<S, any>,
|
|
ActionMatcherDescriptionCollection<S>,
|
|
CaseReducer<S, Action> | undefined,
|
|
] {
|
|
const actionsMap: CaseReducers<S, any> = {}
|
|
const actionMatchers: ActionMatcherDescriptionCollection<S> = []
|
|
let defaultCaseReducer: CaseReducer<S, Action> | undefined
|
|
const builder = {
|
|
addCase(
|
|
typeOrActionCreator: string | TypedActionCreator<any>,
|
|
reducer: CaseReducer<S>,
|
|
) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
/*
|
|
to keep the definition by the user in line with actual behavior,
|
|
we enforce `addCase` to always be called before calling `addMatcher`
|
|
as matching cases take precedence over matchers
|
|
*/
|
|
if (actionMatchers.length > 0) {
|
|
throw new Error(
|
|
'`builder.addCase` should only be called before calling `builder.addMatcher`',
|
|
)
|
|
}
|
|
if (defaultCaseReducer) {
|
|
throw new Error(
|
|
'`builder.addCase` should only be called before calling `builder.addDefaultCase`',
|
|
)
|
|
}
|
|
}
|
|
const type =
|
|
typeof typeOrActionCreator === 'string'
|
|
? typeOrActionCreator
|
|
: typeOrActionCreator.type
|
|
if (!type) {
|
|
throw new Error(
|
|
'`builder.addCase` cannot be called with an empty action type',
|
|
)
|
|
}
|
|
if (type in actionsMap) {
|
|
throw new Error(
|
|
'`builder.addCase` cannot be called with two reducers for the same action type ' +
|
|
`'${type}'`,
|
|
)
|
|
}
|
|
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>,
|
|
) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
if (defaultCaseReducer) {
|
|
throw new Error(
|
|
'`builder.addMatcher` should only be called before calling `builder.addDefaultCase`',
|
|
)
|
|
}
|
|
}
|
|
actionMatchers.push({ matcher, reducer })
|
|
return builder
|
|
},
|
|
addDefaultCase(reducer: CaseReducer<S, Action>) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
if (defaultCaseReducer) {
|
|
throw new Error('`builder.addDefaultCase` can only be called once')
|
|
}
|
|
}
|
|
defaultCaseReducer = reducer
|
|
return builder
|
|
},
|
|
}
|
|
builderCallback(builder)
|
|
return [actionsMap, actionMatchers, defaultCaseReducer]
|
|
}
|