diff --git a/docs/api/createSlice.mdx b/docs/api/createSlice.mdx index 6624b191fe..61a897eda5 100644 --- a/docs/api/createSlice.mdx +++ b/docs/api/createSlice.mdx @@ -138,10 +138,14 @@ const todosSlice = createSlice({ 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 (though for bundle size reasons, you [need a bit of setup for this](#createasyncthunk)). Types are also slightly simplified for prepared reducers. +The main benefit of this is that you can use [custom slice creators](../usage/custom-slice-creators), such as `asyncThunkCreator` which allows creating [async thunks](./createAsyncThunk) as part of your slice (though you [need a bit of setup for this](#createasyncthunk)). Types are also slightly simplified for prepared reducers. ```ts title="Creator callback for reducers" -import { createSlice, nanoid } from '@reduxjs/toolkit' +import { buildCreateSlice, asyncThunkCreator, nanoid } from '@reduxjs/toolkit' + +const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, +}) interface Item { id: string @@ -153,7 +157,7 @@ interface TodoState { todos: Item[] } -const todosSlice = createSlice({ +const todosSlice = createAppSlice({ name: 'todos', initialState: { loading: false, @@ -179,13 +183,13 @@ const todosSlice = createSlice({ return (await res.json()) as Item }, { - pending: (state) => { + pending(state) { state.loading = true }, - rejected: (state, action) => { + rejected(state, action) { state.loading = false }, - fulfilled: (state, action) => { + fulfilled(state, action) { state.loading = false state.todos.push(action.payload) }, @@ -244,7 +248,7 @@ Creates an async thunk instead of an action creator. To avoid pulling `createAsyncThunk` into the bundle size of `createSlice` by default, some extra setup is required to use `create.asyncThunk`. -The version of `createSlice` exported from RTK will throw an error if `create.asyncThunk` is called. +The version of `createSlice` exported from RTK will not include `create.asyncThunk`. Instead, import `buildCreateSlice` and `asyncThunkCreator`, and create your own version of `createSlice`: @@ -258,6 +262,8 @@ export const createAppSlice = buildCreateSlice({ Then import this `createAppSlice` as needed instead of the exported version from RTK. +This same approach can be used to include other [custom creators](../usage/custom-slice-creators). + ::: **Parameters** @@ -278,16 +284,16 @@ create.asyncThunk( return (await res.json()) as Item }, { - pending: (state) => { + pending(state) { state.loading = true }, - rejected: (state, action) => { + rejected(state, action) { state.error = action.payload ?? action.error }, - fulfilled: (state, action) => { + fulfilled(state, action) { state.todos.push(action.payload) }, - settled: (state, action) => { + settled(state, action) { state.loading = false } options: { @@ -662,3 +668,22 @@ console.log(counter.actions.decrement.type) store.dispatch(user.actions.setUserName('eric')) // -> { counter: 12, user: { name: 'eric', age: 22} } ``` + +## `buildCreateSlice` + +`buildCreateSlice` allows you to create a custom version of `createSlice` with some configuration. + +Currently, this is only used for [slice creators](../usage/custom-slice-creators). + +### Parameters + +`buildCreateSlice` accepts a single configuration object parameter, with the following options: + +```ts no-transpile +function buildCreateSlice({ + // An map of creators to names. The names will used when constructing the `create` object passed to `reducers`. + creators?: Record +}) +``` + +It returns an instance of [`createSlice`](#). diff --git a/docs/usage/custom-slice-creators.mdx b/docs/usage/custom-slice-creators.mdx new file mode 100644 index 0000000000..6cfbbdd0b1 --- /dev/null +++ b/docs/usage/custom-slice-creators.mdx @@ -0,0 +1,1391 @@ +--- +id: custom-slice-creators +title: Custom Slice Creators +sidebar_label: Custom Slice Creators +hide_title: true +--- + +# Custom Slice Creators + +Redux Toolkit 2.0 introduces the concept of "slice creators", which allow you to define reusable logic for creating slice reducers. + +These "creators" have the capability to: + +- Define multiple reducers at the same time +- Modify slice behaviour by adding case/matcher reducers +- Expose custom actions (thunks, for example) and case reducers + +```ts no-transpile +const createAppSlice = buildCreateSlice({ + creators: { historyMethods: historyCreator, undoable: undoableCreator }, +}) + +const postSliceWithHistory = createAppSlice({ + name: 'post', + initialState: getInitialHistoryState({ title: '', pinned: false }), + reducers: (create) => ({ + ...create.historyMethods(), + updateTitle: create.preparedReducer( + create.undoable.withPayload(), + create.undoable((state, action) => { + state.title = action.payload + }), + ), + togglePinned: create.preparedReducer( + create.undoable.withoutPayload(), + create.undoable((state, action) => { + state.pinned = !state.pinned + }), + ), + }), +}) + +const { undo, redo, reset, updateTitle, togglePinned } = + postSliceWithHistory.actions +``` + +## The `reducers` "creator callback" notation + +In order to use slice creators, `reducers` becomes a callback, which receives a `create` object. This `create` object contains a couple of [inbuilt creators](#rtk-creators), along with any creators passed to [`buildCreateSlice`](../api/createSlice#buildcreateslice). + +```ts title="Creator callback for reducers" +import { buildCreateSlice, asyncThunkCreator, nanoid } from '@reduxjs/toolkit' + +const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, +}) + +interface Item { + id: string + text: string +} + +interface TodoState { + loading: boolean + todos: Item[] +} + +const todosSlice = createAppSlice({ + name: 'todos', + initialState: { + loading: false, + todos: [], + } as TodoState, + reducers: (create) => ({ + deleteTodo: create.reducer((state, action) => { + 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 +``` + +### RTK Creators + +These creators come built into RTK, and are always available on the `create` object passed to the `reducers` callback. + +#### `create.reducer` + +A standard slice case reducer. Creates an action creator with the same name as the reducer. + +**Parameters** + +- **reducer** The slice case reducer to use. + +```ts no-transpile +create.reducer((state, action) => { + state.todos.push(action.payload) +}) +``` + +:::tip + +The [creator definition](#creator-definitions) for `create.reducer` is exported from RTK as `reducerCreator`, to allow reuse. + +::: + +#### `create.preparedReducer` + +A [prepared](../api/createSlice#customizing-generated-action-creators) reducer, to customize the action creator. Creates a prepared action creator with the same name as the reducer. + +**Parameters** + +- **prepareAction** The [`prepare callback`](../api/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) + }, +) +``` + +:::tip + +The [creator definition](#creator-definitions) for `create.preparedReducer` is exported from RTK as `preparedReducerCreator`, to allow reuse. + +::: + +### Optional RTK Creators + +These creators are not included in the default `create` object, but can be added by passing them to [`buildCreateSlice`](../api/createSlice#buildcreateslice). + +The name the creator is available under is based on the key used when calling `buildCreateSlice`. For example, to use `create.asyncThunk`: + +```ts +import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit' + +export const createAppSlice = buildCreateSlice({ + creators: { + // highlight-next-line + asyncThunk: asyncThunkCreator, + }, +}) + +interface Post { + id: string + text: string +} + +export const postsSlice = createAppSlice({ + name: 'posts', + initialState: [] as Post[], + reducers: (create) => ({ + // highlight-next-line + fetchPosts: create.asyncThunk( + async () => { + const res = await fetch('myApi/posts') + return (await res.json()) as Post[] + }, + { + fulfilled(state, action) { + return action.payload + }, + }, + ), + }), +}) +``` + +For clarity these docs will use recommended names. + +:::tip + +We recommend using `createAppSlice` consistently throughout your app as a replacement for `createSlice`. + +This avoids having to consider whether the creators are needed for each slice. + +::: + +:::caution + +To avoid collision, names used by [RTK creators](#rtk-creators) are reserved - passing creators under the `reducer` or `preparedReducer` keys is not allowed, and only `asyncThunkCreator` is allowed to be passed under the `asyncThunk` key. + +```ts no-transpile +const createAppSlice = buildCreateSlice({ + creators: { + reducer: aCustomCreator, // not allowed, name is reserved + asyncThunk: aCustomCreator, // not allowed, must be asyncThunkCreator + asyncThunk: asyncThunkCreator, // allowed + }, +}) +``` + +::: + +#### `create.asyncThunk` (`asyncThunkCreator`) + +Creates an async thunk and adds any provided case reducers for lifecycle actions. + +**Parameters** + +- **payloadCreator** The thunk [payload creator](../api/createAsyncThunk#payloadcreator). +- **config** The configuration object. (optional) + +The configuration object can contain case reducers for each of the [lifecycle actions](../api/createAsyncThunk#promise-lifecycle-actions) (`pending`, `fulfilled`, and `rejected`), as well as a `settled` reducer that will run for both fulfilled and rejected actions (note that this will run _after_ any provided `fulfilled`/`rejected` reducers. Conceptually it can be thought of like a `finally` block.). + +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`](../api/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.error = action.payload ?? action.error + }, + fulfilled: (state, action) => { + state.todos.push(action.payload) + }, + settled: (state, action) => { + state.loading = false + } + options: { + idGenerator: uuid, + }, + } +) +``` + +:::note + +Typing for `create.asyncThunk` works in the same way as [`createAsyncThunk`](./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 - `getState() as RootState`. You may also include an explicit return type for the payload function as well, in order to break the circular type inference cycle. + +```ts no-transpile +create.asyncThunk( + // highlight-start + // may need to include an explicit return type + async (id: string, thunkApi): Promise => { + // Cast types for `getState` and `dispatch` manually + const state = thunkApi.getState() as RootState + const dispatch = thunkApi.dispatch as AppDispatch + // highlight-end + try { + const todo = await fetchTodo() + return todo + } catch (e) { + throw thunkApi.rejectWithValue({ + error: 'Oh no!', + }) + } + }, +) +``` + +For common thunk API configuration options, a [`withTypes` helper](./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!', + }) + }), + } +} +``` + +::: + +## Writing your own creators + +In version v2.3.0, we introduced a system for including your own creators. + +The below documentation will cover how to write your own creators, and how to use them with `createSlice`. + +### Reducer definitions + +A reducer definition is an object (or function) with a `_reducerDefinitionType` property indicating which creator should handle it. Other than this property, it is entirely up to the creator what this definition object can look like. + +For example, the `create.preparedReducer` creator uses a definition that looks like `{ prepare, reducer }`. + +The callback form of `reducers` should return an object of reducer definitions, by calling creators and nesting the result of each under a key. + +```js no-transpile +reducers: (create) => ({ + addTodo: create.preparedReducer( + (todo) => ({ payload: { id: nanoid(), ...todo } }), + (state, action) => { + state.push(action.payload) + }, + ), +}) +// becomes +const definitions = { + addTodo: { + _reducerDefinitionType: 'reducerWithPrepare', + prepare: (todo) => ({ payload: { id: nanoid(), ...todo } }), + reducer: (state, action) => { + state.push(action.payload) + }, + }, +} +``` + +Typically a creator will return a [single reducer definition](#single-definitions), but it could return an object of [multiple definitions](#multiple-definitions) to be spread into the final object, or [something else entirely](#other)! + +### Creator definitions + +A creator definition contains the actual runtime logic for that creator. It's an object with a `type` property, a `create` value (typically a function or set of functions), and an optional `handle` method. + +It's passed to [`buildCreateSlice`](../api/createSlice#buildcreateslice) as part of the `creators` object, and the name used when calling `buildCreateSlice` will be the key the creator is nested under in the `create` object. + +```ts no-transpile +import { buildCreateSlice } from '@reduxjs/toolkit' + +const createAppSlice = buildCreateSlice({ + // highlight-next-line + creators: { batchable: batchableCreator }, +}) + +const todoSlice = createSlice({ + name: 'todos', + initialState: [] as Todo[], + reducers: (create) => ({ + // highlight-next-line + addTodo: create.batchable((state, action) => { + state.push(action.payload) + }), + }), +}) +``` + +The `type` property of the definition should be the same constant used for reducer definitions to be handled by this creator. To avoid collision, we recommend using Symbols for this. It's also used for defining/retrieving types - see [Typescript](#typescript). + +```ts no-transpile +const reducerCreatorType = Symbol('reducerCreatorType') + +const reducerCreator: ReducerCreator = { + type: reducerCreatorType, + create(reducer) { + return { + _reducerDefinitionType: reducerCreatorType, + reducer, + } + }, + handle({ type }, definition, context) { + const { reducer } = definition + const actionCreator = createAction(type) + context + .addCase(actionCreator, reducer) + .exposeAction(actionCreator) + .exposeCaseReducer(reducer) + }, +} +``` + +#### `create` + +The `create` value will be attached to the `create` object, before it's passed to the `reducers` callback. + +If it's a function, the `this` value will be the final `create` object when called (assuming a `create.creator()` call). It also could have additional methods attached, or be an object with methods. + +See the [Further examples](#further-examples) section for some examples of these. + +### `handle` + +The `handle` callback of a creator will be called for any reducer definitions with a matching `_reducerDefinitionType` property. + +:::note +A creator only needs a `handle` callback if it expects to be called with reducer definitions. If it only calls other creators (see [Using `this` to access other creators](#create-1)), it can omit the `handle`. +::: + +It receives three arguments: details about the reducer, the definition, and a `context` object with methods to modify the slice. + +The reducer details object has the following properties: + +- `sliceName` - the name of the slice the reducer is being added to (e.g. `entities/todos`) +- `reducerPath` - the `reducerPath` passed to `createSlice` (e.g. `todos`) (defaults to `sliceName` if not provided) +- `reducerName` - the key the reducer definition was under (e.g. `addTodo`) +- `type` - the automatically generated type string for the reducer (e.g. `entities/todos/addTodo`) + +The context object includes: + +#### `addCase` + +The same as [`addCase`](../api/createReducer#builderaddcase) for `createReducer` and `extraReducers`. Adds a case reducer for a given action type, and can receive an action type string or an action creator with a `.type` property. + +```ts no-transpile +const action = createAction(type) +context.addCase(action, reducer) +// or +context.addCase(type, reducer) +``` + +Returns the context object to allow chaining. + +#### `addMatcher` + +The same as [`addMatcher`](../api/createReducer#builderaddmatcher) for `createReducer` and `extraReducers`. Adds a case reducer which will be called when a given matcher returns true. + +```ts no-transpile +const matcher = isAnyOf(action, action2) +context.addMatcher(matcher, reducer) +``` + +Returns the context object to allow chaining. + +:::tip + +Unlike in `createReducer` and `extraReducers`, there is no requirement to call `addMatcher` _after_ `addCase` - the correct order will be applied when the reducer is built. + +You should still be aware that case reducers will be called before matcher reducers, if both match a given action. + +::: + +#### `exposeAction` + +Attaches a value to `slice.actions[reducerName]`. + +```ts no-transpile +const action = createAction(type) +context.exposeAction(action) +``` + +Returns the context object to allow chaining. + +:::caution + +`exposeAction` should only be called once (at maximum) within a `handle` callback. + +If you want to expose multiple values for a given case reducer's actions, you can pass an object to `exposeAction`. + +```ts no-transpile +context.exposeAction({ hidden: hiddenAction, shown: shownAction }) +// available as slice.actions[reducerName].hidden and slice.actions[reducerName].shown +``` + +::: + +#### `exposeCaseReducer` + +Attaches a value to `slice.caseReducers[reducerName]`. + +```ts no-transpile +context.exposeCaseReducer(reducer) +``` + +Returns the context object to allow chaining. + +:::caution + +Just like `exposeAction`, `exposeCaseReducer` should only be called once (at maximum) within a `handle` callback. + +If you want to expose multiple values for a given case reducer definition, you can pass an object to `exposeCaseReducer`. + +```ts no-transpile +context.exposeCaseReducer({ + hidden: config.hidden || noop, + shown: config.shown || noop, +}) +// available as slice.caseReducers[reducerName].hidden and slice.caseReducers[reducerName].shown +``` + +You can see an example of this with the `asyncThunk` creator in the [RTK creators](#rtk-creators) section, which exposes case reducers for each of the lifecycle actions. + +```ts no-transpile +const asyncThunkCreator: ReducerCreator = { + type: asyncThunkCreatorType, + create(payloadCreator, config) { + return { + _reducerDefinitionType: asyncThunkCreatorType, + payloadCreator, + config, + } + }, + handle({ type }, definition, context) { + const { payloadCreator, config } = definition + const thunk = createAsyncThunk(type, payloadCreator, config) + context.exposeAction(thunk).exposeCaseReducer({ + pending: config.pending || noop, + rejected: config.rejected || noop, + fulfilled: config.fulfilled || noop, + settled: config.settled || noop, + }) + }, +} +``` + +::: + +#### `getInitialState` + +Returns the initial state value for the slice. If a lazy state initializer has been provided, it will be called and a fresh value returned. + +```ts no-transpile +const resetAction = createAction(type + '/reset') +const resetReducer = () => context.getInitialState() +context + .addCase(resetAction, resetReducer) + .exposeAction({ reset: resetAction }) + .exposeCaseReducer({ reset: resetReducer }) +``` + +#### `selectSlice` + +Tries to select the slice's state from the root state, using the original `reducerPath` option passed when calling `createSlice` (which defaults to the `name` option). Throws an error if it can't find the slice. + +```ts no-transpile +const aThunk = + (): ThunkAction, unknown, Action> => + (dispatch, getState) => { + const state = context.selectSlice(getState()) + // do something with state + } +``` + +### Typescript + +The Typescript system for custom slice creators uses a "creator registry" system similar to the module system for [RTK Query](/rtk-query/usage/customizing-create-api#creating-your-own-module). + +Creators are registered by using module augmentation to add a new key (their unique `type`) to the `SliceReducerCreators` interface. Each entry should use the `ReducerCreatorEntry` type utility. + +```ts no-transpile +const reducerCreatorType = Symbol('reducerCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [reducerCreatorType]: ReducerCreatorEntry< + () => ReducerDefinition + > + } +} +``` + +The type parameters for `SliceReducerCreators` are: + +- `State` - The state type used by the slice. +- `CaseReducers` - The case reducer definitions returned by the creator callback. +- `Name` - The [`name`](../api/createSlice#name) used by the slice. +- `ReducerPath` - The [`reducerPath`](../api/createSlice#reducerpath) used by the slice. + +The `ReducerCreatorEntry` utility has two type parameters: + +#### `Create` + +The type of the `create` value of the creator definition (typically a function signature). + +:::caution `CaseReducers` + +Your `Create` type should not depend on the `CaseReducers` type parameter, as these will not yet exist when the creator is being called. + +::: + +:::tip Using `this` to access other creators + +Assuming the creator is called as `create.yourCreator()`, the `this` value for the function is the `create` object - meaning you can call other creators on the same object. + +However, this should be specifically included in the function signature, so Typescript can warn if called with an incorrect context (for example, if the user destructures from the `create` value). + +```ts no-transpile +const batchedCreatorType = Symbol('batchedCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [batchedCreatorType]: ReducerCreatorEntry< + ( + // highlight-next-line + this: ReducerCreators, + reducer: CaseReducer>, + ) => PreparedCaseReducerDefinition< + State, + (payload: Payload) => { payload: Payload; meta: unknown } + > + > + } +} + +const batchedCreator: ReducerCreator = { + type: batchedCreatorType, + create(reducer) { + return this.preparedReducer(prepareAutoBatched(), reducer) + }, +} +``` + +The second argument to the `ReducerCreators` type is a map from creator names to types, which you should supply if you're expecting to use any custom creators (anything other than `reducer` and `preparedReducer`) within your own creator. For example, `ReducerCreators` would allow you to call `this.asyncThunk`. + +Alternatively, you can import the other creator's definition and use it directly. + +```ts no-transpile +import { + preparedReducerCreator, + PreparedCaseReducerDefinition, +} from '@reduxjs/toolkit' + +const batchedCreatorType = Symbol('batchedCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [batchedCreatorType]: ReducerCreatorEntry< + ( + reducer: CaseReducer>, + ) => PreparedCaseReducerDefinition< + State, + (payload: Payload) => { payload: Payload; meta: unknown } + > + > + } +} + +const batchedCreator: ReducerCreator = { + type: batchedCreatorType, + create(reducer) { + return preparedReducerCreator.create(prepareAutoBatched(), reducer) + }, +} +``` + +::: + +:::note Ensuring compatible state + +Sometimes it's useful to have a reducer creator that only works with a specific state shape. You can ensure the creator is only callable if the state matches, using a conditional type: + +```ts no-transpile +const loaderCreatorType = Symbol('loaderCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [loaderCreatorType]: ReducerCreatorEntry< + // highlight-next-line + State extends { loading: boolean } + ? () => { + start: CaseReducerDefinition + end: CaseReducerDefinition + } + : never + > + } +} +``` + +Any creators that evaluate to the `never` type are omitted from the final `create` object. + +An alternative would be just using that required type _as_ the `State` type for the reducer definitions, so Typescript then complains when the creator is used. + +```ts no-transpile +const loaderCreatorType = Symbol('loaderCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [loaderCreatorType]: ReducerCreatorEntry< + () => { + start: CaseReducerDefinition<{ loading: boolean }, PayloadAction> + end: CaseReducerDefinition<{ loading: boolean }, PayloadAction> + } + > + } +} +``` + +::: + +#### `Exposes` (optional) + +The second type parameter for `ReducerCreatorEntry` is optional, but should be used if the creator will handle some reducer definitions itself. It indicates what actions and case reducers will be attached to the slice, and is used to determine the final types of `slice.actions` and `slice.caseReducers`. + +It should be an object with some of the following properties: + +##### `actions` + +The actions property will typically be a [mapped type](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html) over the `CaseReducers` type parameter, returning what the creator's `handle` would expose when given that definition. + +In order to ensure that the definitions are correctly filtered to only include those handled by the creator, a conditional type should be used, typically checking the definition extends a `ReducerDefinition` with the right type. + +```ts no-transpile +{ + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition ? ActionTypeHere : never +} +``` + +To relate back to the context methods, it should describe what you will pass to `context.exposeAction` from a handler. + +For example, with (a simplified version of) the `asyncThunk` creator: + +```ts no-transpile +const asyncThunkCreatorType = Symbol('asyncThunkCreatorType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [asyncThunkCreatorType]: ReducerCreatorEntry< + ( + payloadCreator: AsyncThunkPayloadCreator, + ) => AsyncThunkReducerDefinition, + { + // highlight-start + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkReducerDefinition< + State, + infer ThunkArg, + infer Returned + > + ? AsyncThunk + : never + } + // highlight-end + } + > + } +} +``` + +##### `caseReducers` + +Similar to `actions`, except for `slice.caseReducers`. + +It describes what you will pass to `context.exposeCaseReducer` from a handler. + +For example, with the `preparedReducer` creator: + +```ts no-transpile +const preparedReducerType = Symbol('preparedReducerType') + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [preparedReducerType]: ReducerCreatorEntry< + >( + prepare: Prepare, + caseReducer: CaseReducer>, + ) => PreparedCaseReducerDefinition, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends PreparedCaseReducerDefinition< + any, + any + > + ? ActionCreatorForCaseReducerWithPrepare< + CaseReducers[ReducerName], + SliceActionType + > + : never + } + // highlight-start + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends PreparedCaseReducerDefinition< + any, + any + > + ? CaseReducers[ReducerName]['reducer'] + : never + } + // highlight-end + } + > + } +} +``` + +### Further examples + +This section will cover in depth examples, for potential applications with hopefully applicable lessons to other use cases. + +If you come up with a novel use for reducer creators, we'd love to hear it! It should even be possible to publish packages with creators for others to use. + +:::note `buildCreateSlice` usage + +For the sake of a complete example, most of the snippets below will include a `buildCreateSlice` call. + +In practicality, we expect that most apps will only call `buildCreateSlice` _once_, with as many creators as needed in that app. + +```ts no-transpile +export const createAppSlice = buildCreateSlice({ + creators: { + toaster: toastCreator, + paginationMethods: paginationCreator, + historyMethods: historyCreator, + undoable: undoableCreator, + }, +}) +``` + +::: + +#### Single definitions + +Commonly, a creator will return a single reducer definition, to be handled by either itself or another creator. + +One example would be reusable toast logic; you could have a reducer creator that makes a thunk creator. That thunk would dispatch an "show" action immediately when called, and then dispatch a second "hide" action after a given amount of time. + +```ts no-transpile +// create the unique type +const toastCreatorType = Symbol('toastCreatorType') + +interface Toast { + message: string +} + +interface ToastReducerConfig { + shown?: CaseReducer> + hidden?: CaseReducer> +} + +interface ToastReducerDefinition + extends ReducerDefinition, + ToastReducerConfig {} + +interface ToastThunkCreator< + SliceName extends string, + ReducerName extends string, +> { + ( + toast: Toast, + timeout?: number, + ): ThunkAction<{ hide(): void }, unknown, unknown, UnknownAction> + shown: PayloadActionCreator< + Toast, + `${SliceActionType}/shown`, + (toast: Toast, id: string) => { payload: Toast; meta: { id: string } } + > + hidden: PayloadActionCreator< + void, + `${SliceActionType}/hidden`, + (id: string) => { payload: undefined; meta: { id: string } } + > +} + +// register the creator types +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [toastCreatorType]: ReducerCreatorEntry< + (config: ToastReducerConfig) => ToastReducerDefinition, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ToastReducerDefinition + ? ToastThunkCreator + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ToastReducerDefinition + ? Required> + : never + } + } + > + } +} + +// define the creator +const toastCreator: ReducerCreator = { + type: toastCreatorType, + // return the reducer definition + create(config) { + return { + _reducerDefinitionType: toastCreatorType, + ...config, + } + }, + // handle the reducer definition + handle({ type }, definition, context) { + // make the action creators + const shown = createAction(type + '/shown', (toast: Toast, id: string) => ({ + payload: toast, + meta: { id }, + })) + const hidden = createAction(type + '/hidden', (id: string) => ({ + payload: undefined, + meta: { id }, + })) + // make the thunk creator + function thunkCreator( + toast: Toast, + timeout = 300, + ): ThunkAction<{ hide(): void }, unknown, unknown, UnknownAction> { + return (dispatch, getState) => { + const id = nanoid() + dispatch(shown(toast, id)) + const timeoutId = setTimeout(() => dispatch(hidden(id)), timeout) + return { + hide() { + clearTimeout(timeoutId) + dispatch(hidden(id)) + }, + } + } + } + // attach the action creators to the thunk creator + Object.assign(thunkCreator, { shown, hidden }) + + // add any case reducers passed in the config + if (definition.shown) { + context.addCase(shown, definition.shown) + } + if (definition.hidden) { + context.addCase(hidden, definition.hidden) + } + + // expose the thunk creator as `slice.actions[reducerName]` and the case reducers as `slice.caseReducers[reducerName]["shown" | "hidden"]` + context.exposeAction(thunkCreator).exposeCaseReducer({ + shown: definition.shown || noop, + hidden: definition.hidden || noop, + }) + }, +} + +function noop() {} + +// build the `createSlice` function +const createAppSlice = buildCreateSlice({ + creators: { toaster: toastCreator }, +}) + +const toastSlice = createAppSlice({ + name: 'toast', + initialState: {} as Record, + reducers: (create) => ({ + // call creator to get definition, and save it to a key + showToast: create.toaster({ + shown(state, action) { + state[action.meta.id] = action.payload + }, + hidden(state, action) { + delete state[action.meta.id] + }, + }), + }), +}) + +// showToast is the thunk creator from above +const { showToast } = toastSlice.actions + +// case reducers and action creators are available where we put them +toastSlice.caseReducers.showToast.hidden({}, showToast.hidden('id')) +``` + +#### Multiple definitions + +A creator could also return multiple definitions, which would then be spread into the final definitions object. This is a more composable alternative to the [wrapping `createSlice`](./usage-with-typescript#wrapping-createslice) approach, as you could call multiple creators as needed. + +One example could be returning some pagination related reducers. + +```ts no-transpile +const paginationCreatorType = Symbol('paginationCreatorType') + +interface PaginationState { + page: number +} + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [paginationCreatorType]: ReducerCreatorEntry< + // make sure the creator is only called when state is compatible + State extends PaginationState + ? (this: ReducerCreators) => { + prevPage: CaseReducerDefinition + nextPage: CaseReducerDefinition + goToPage: CaseReducerDefinition> + } + : never + > + } +} + +const paginationCreator: ReducerCreator = { + type: paginationCreatorType, + create() { + return { + // calling `this.reducer` assumes we'll be calling the creator as `create.paginationMethods()` + // if we don't want this assumption, we could use `reducerCreator.create` instead + prevPage: this.reducer((state: PaginationState) => { + state.page-- + }), + nextPage: this.reducer((state: PaginationState) => { + state.page++ + }), + goToPage: this.reducer((state: PaginationState, action) => { + state.page = action.payload + }), + } + }, +} + +const createAppSlice = buildCreateSlice({ + creators: { paginationMethods: paginationCreator }, +}) + +const paginationSlice = createAppSlice({ + name: 'pagination', + initialState: { page: 0, loading: false }, + reducers: (create) => ({ + ...create.paginationMethods(), + toggleLoading: create.reducer((state) => { + state.loading = !state.loading + }), + }), +}) + +const { prevPage, nextPage, goToPage, toggleLoading } = paginationSlice.actions +``` + +A creator could return a mix of reducer definitions for itself and other creators to handle: + +```ts no-transpile +const historyCreatorType = Symbol('historyCreatorType') + +interface PatchesState { + undo: Patch[] + redo: Patch[] +} + +interface HistoryState { + past: PatchesState[] + present: T + future: PatchesState[] +} + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [historyCreatorType]: ReducerCreatorEntry< + // make sure the creator is only called when state is compatible + State extends HistoryState + ? (this: ReducerCreators) => { + undo: CaseReducerDefinition + redo: CaseReducerDefinition + reset: ReducerDefinition & { + type: 'reset' + } + } + : never, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof historyCreatorType + > + ? CaseReducers[ReducerName] extends { type: 'reset' } + ? PayloadActionCreator> + : never + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof historyCreatorType + > + ? CaseReducers[ReducerName] extends { type: 'reset' } + ? CaseReducer + : never + : never + } + } + > + } +} + +const historyCreator: ReducerCreator = { + type: historyCreatorType, + create() { + return { + undo: this.reducer((state: HistoryState) => { + const historyEntry = state.past.pop() + if (historyEntry) { + applyPatches(state, historyEntry.undo) + state.future.unshift(historyEntry) + } + }), + redo: this.reducer((state: HistoryState) => { + const historyEntry = state.future.shift() + if (historyEntry) { + applyPatches(state, historyEntry.redo) + state.past.push(historyEntry) + } + }), + // highlight-start + // here we're creating a reducer definition that our `handle` method will be called with + reset: { + _reducerDefinitionType: historyCreatorType, + type: 'reset', + }, + // highlight-end + } + }, + handle(details, definition, context) { + if (definition.type !== 'reset') { + throw new Error('Unrecognised definition type: ' + definition.type) + } + const resetReducer = () => context.getInitialState() + // you can call other creators' `handle` methods if needed + // here we're reusing `reducerCreator` to get the expected behaviour of making an action creator for our reducer + reducerCreator.handle(details, reducerCreator.create(resetReducer), context) + }, +} + +const createAppSlice = buildCreateSlice({ + creators: { historyMethods: historyCreator }, +}) + +function getInitialHistoryState(initialState: T): HistoryState { + return { + past: [], + present: initialState, + future: [], + } +} + +const postSliceWithHistory = createAppSlice({ + name: 'post', + initialState: getInitialHistoryState({ title: '' }), + reducers: (create) => ({ + ...create.historyMethods(), + }), +}) + +const { undo, redo, reset } = postSliceWithHistory.actions +``` + +#### Other + +A creator doesn't have to return any reducer definitions, it could be any sort of utility for defining reducers. + +Following on from the `HistoryState` example above, it would be useful to make some sort of `undoable` utility to wrap reducers in logic which automatically updates the history of the slice. + +Fortunately, this is possible with a creator: + +```ts no-transpile +const undoableCreatorType = Symbol('undoableCreatorType') + +interface UndoableMeta { + undoable?: boolean +} + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [undoableCreatorType]: ReducerCreatorEntry< + State extends HistoryState + ? { + ( + reducer: CaseReducer, + ): CaseReducer + withoutPayload(options?: UndoableMeta): { + payload: undefined + meta: UndoableMeta | undefined + } + withPayload( + payload: Payload, + options?: UndoableMeta, + ): { payload: Payload; meta: UndoableMeta | undefined } + } + : never + > + } +} + +const undoableCreator: ReducerCreator = { + type: undoableCreatorType, + create: Object.assign( + function makeUndoable( + reducer: CaseReducer, + ): CaseReducer, A> { + return (state, action) => { + const [nextState, redoPatches, undoPatches] = produceWithPatches( + state, + (draft) => { + const result = reducer(draft.present, action) + if (typeof result !== 'undefined') { + draft.present = result + } + }, + ) + let finalState = nextState + const undoable = action.meta?.undoable ?? true + if (undoable) { + finalState = createNextState(finalState, (draft) => { + draft.past.push({ + undo: undoPatches, + redo: redoPatches, + }) + draft.future = [] + }) + } + return finalState + } + }, + { + withoutPayload() { + return (options?: UndoableOptions) => ({ + payload: undefined, + meta: options, + }) + }, + withPayload

() { + return ( + ...[payload, options]: IfMaybeUndefined< + P, + [payload?: P, options?: UndoableOptions], + [payload: P, options?: UndoableOptions] + > + ) => ({ payload: payload as P, meta: options }) + }, + }, + ), +} + +const createAppSlice = buildCreateSlice({ + creators: { historyMethods: historyCreator, undoable: undoableCreator }, +}) + +const postSliceWithHistory = createAppSlice({ + name: 'post', + initialState: getInitialHistoryState({ title: '', pinned: false }), + reducers: (create) => ({ + ...create.historyMethods(), + updateTitle: create.preparedReducer( + create.undoable.withPayload(), + create.undoable((state, action) => { + state.title = action.payload + }), + ), + togglePinned: create.preparedReducer( + create.undoable.withoutPayload(), + create.undoable((state, action) => { + state.pinned = !state.pinned + }), + ), + }), +}) + +const { undo, redo, reset, updateTitle, togglePinned } = + postSliceWithHistory.actions +``` + +:::tip `history-adapter` + +This example is a somewhat simplified version of the [`history-adapter`](https://www.npmjs.com/package/history-adapter) package, which provides a `createHistoryAdapter` utility that can be used to add undo/redo functionality to a slice. + +```ts no-transpile +import { + createHistoryAdapter, + historyMethodsCreator, + undoableCreatorsCreator, +} from 'history-adapter/redux' + +const createAppSlice = buildCreateSlice({ + creators: { + historyMethods: historyMethodsCreator, + undoableCreators: undoableCreatorsCreator, + }, +}) + +const postHistoryAdapter = createHistoryAdapter({ limit: 5 }) + +const postSliceWithHistory = createAppSlice({ + name: 'post', + initialState: postHistoryAdapter.getInitialState({ + title: '', + pinned: false, + }), + reducers: (create) => { + const createUndoable = create.undoableCreators(postHistoryAdapter) + return { + ...create.historyMethods(postHistoryAdapter), + updateTitle: createUndoable.preparedReducer( + postHistoryAdapter.withPayload(), + (state, action) => { + state.title = action.payload + }, + ), + togglePinned: createUndoable.preparedReducer( + postHistoryAdapter.withoutPayload(), + (state, action) => { + state.pinned = !state.pinned + }, + ), + } + }, +}) + +const { undo, redo, reset, updateTitle, togglePinned } = + postSliceWithHistory.actions +``` + +::: diff --git a/errors.json b/errors.json index 3c3d953125..4bdd52e2b6 100644 --- a/errors.json +++ b/errors.json @@ -38,5 +38,13 @@ "36": "When using custom hooks for context, all hooks need to be provided: .\\nHook was either not provided or not a function.", "37": "Warning: Middleware for RTK-Query API at reducerPath \"\" has not been added to the store.\n You must add the middleware for RTK-Query to function correctly!", "38": "Cannot refetch a query that has not been started yet.", - "39": "called \\`injectEndpoints\\` to override already-existing endpointName without specifying \\`overrideExisting: true\\`" -} \ No newline at end of file + "39": "Cannot use reserved creator name: name", + "40": "Please use reducer creators passed to callback. Each reducer definition must have a `_reducerDefinitionType` property indicating which handler to use.", + "41": "Cannot use reserved creator type: ", + "42": "Unsupported reducer type: ", + "43": "If provided, `asyncThunk` creator must be `asyncThunkCreator` from '@reduxjs/toolkit'", + "44": "called \\`injectEndpoints\\` to override already-existing endpointName without specifying \\`overrideExisting: true\\`", + "45": "context.exposeAction cannot be called twice for the same reducer definition: reducerName", + "46": "context.exposeCaseReducer cannot be called twice for the same reducer definition: reducerName", + "47": "Could not find \"\" slice in state. In order for slice creators to use \\`context.selectSlice\\`, the slice must be nested in the state under its reducerPath: \"\"" +} diff --git a/examples/type-portability/bundler/src/features/polling/pollingSlice.ts b/examples/type-portability/bundler/src/features/polling/pollingSlice.ts index c55eed9266..1ed696c8d3 100644 --- a/examples/type-portability/bundler/src/features/polling/pollingSlice.ts +++ b/examples/type-portability/bundler/src/features/polling/pollingSlice.ts @@ -1,4 +1,3 @@ -import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { RootState } from '../../app/store' @@ -37,27 +36,22 @@ export type PollingAppKey = keyof (typeof initialState)['apps'] export const pollingSlice = createSlice({ name: 'polling', initialState, - reducers: (creators) => { + reducers: (create) => { return { - toggleGlobalPolling: creators.reducer((state) => { + toggleGlobalPolling: create.reducer((state) => { state.enabled = !state.enabled }), - updatePolling( - state, - { - payload, - }: PayloadAction<{ - app: PollingAppKey - enabled?: boolean - interval?: number - }>, - ) { + updatePolling: create.reducer<{ + app: PollingAppKey + enabled?: boolean + interval?: number + }>((state, { payload }) => { const { app, ...rest } = payload state.apps[app] = { ...state.apps[app], ...rest, } - }, + }), } }, selectors: { diff --git a/examples/type-portability/nodenext-cjs/src/features/polling/pollingSlice.ts b/examples/type-portability/nodenext-cjs/src/features/polling/pollingSlice.ts index 24ffcc115c..c70f0b539d 100644 --- a/examples/type-portability/nodenext-cjs/src/features/polling/pollingSlice.ts +++ b/examples/type-portability/nodenext-cjs/src/features/polling/pollingSlice.ts @@ -1,6 +1,5 @@ import ReduxToolkit = require('@reduxjs/toolkit') -import type { PayloadAction } from '@reduxjs/toolkit' import type { RootState } from '../../app/store.js' namespace pollingSliceModule { @@ -41,27 +40,22 @@ namespace pollingSliceModule { export const pollingSlice = createSlice({ name: 'polling', initialState, - reducers: (creators) => { + reducers: (create) => { return { - toggleGlobalPolling: creators.reducer((state) => { + toggleGlobalPolling: create.reducer((state) => { state.enabled = !state.enabled }), - updatePolling( - state, - { - payload, - }: PayloadAction<{ - app: PollingAppKey - enabled?: boolean - interval?: number - }>, - ) { + updatePolling: create.reducer<{ + app: PollingAppKey + enabled?: boolean + interval?: number + }>((state, { payload }) => { const { app, ...rest } = payload state.apps[app] = { ...state.apps[app], ...rest, } - }, + }), } }, selectors: { diff --git a/examples/type-portability/nodenext-esm/src/features/polling/pollingSlice.ts b/examples/type-portability/nodenext-esm/src/features/polling/pollingSlice.ts index a1daf6da2a..f9f5e5b8eb 100644 --- a/examples/type-portability/nodenext-esm/src/features/polling/pollingSlice.ts +++ b/examples/type-portability/nodenext-esm/src/features/polling/pollingSlice.ts @@ -1,4 +1,3 @@ -import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { RootState } from '../../app/store.js' @@ -37,27 +36,22 @@ export type PollingAppKey = keyof (typeof initialState)['apps'] export const pollingSlice = createSlice({ name: 'polling', initialState, - reducers: (creators) => { + reducers: (create) => { return { - toggleGlobalPolling: creators.reducer((state) => { + toggleGlobalPolling: create.reducer((state) => { state.enabled = !state.enabled }), - updatePolling( - state, - { - payload, - }: PayloadAction<{ - app: PollingAppKey - enabled?: boolean - interval?: number - }>, - ) { + updatePolling: create.reducer<{ + app: PollingAppKey + enabled?: boolean + interval?: number + }>((state, { payload }) => { const { app, ...rest } = payload state.apps[app] = { ...state.apps[app], ...rest, } - }, + }), } }, selectors: { diff --git a/packages/toolkit/src/asyncThunkCreator.ts b/packages/toolkit/src/asyncThunkCreator.ts new file mode 100644 index 0000000000..a2a974a1bf --- /dev/null +++ b/packages/toolkit/src/asyncThunkCreator.ts @@ -0,0 +1,192 @@ +import type { + AsyncThunk, + AsyncThunkConfig, + AsyncThunkOptions, + AsyncThunkPayloadCreator, + OverrideThunkApiConfigs, +} from './createAsyncThunk' +import { createAsyncThunk } from './createAsyncThunk' +import type { CaseReducer } from './createReducer' +import type { + CreatorCaseReducers, + ReducerCreator, + ReducerDefinition, +} from './createSlice' +import { ReducerType } from './createSlice' +import type { Id } from './tsHelpers' + +export type AsyncThunkCreatorExposes< + State, + CaseReducers extends CreatorCaseReducers, +> = { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< + State, + infer ThunkArg, + infer Returned, + infer ThunkApiConfig + > + ? AsyncThunk + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< + State, + any, + any, + any + > + ? Id< + Pick< + Required, + 'fulfilled' | 'rejected' | 'pending' | 'settled' + > + > + : never + } +} + +export type 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']> + > + settled?: CaseReducer< + State, + ReturnType< + AsyncThunk['rejected' | '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] +} + +export interface AsyncThunkCreator< + State, + CurriedThunkApiConfig extends + PreventCircular = PreventCircular, +> { + ( + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + CurriedThunkApiConfig + >, + config?: AsyncThunkSliceReducerConfig< + State, + ThunkArg, + Returned, + CurriedThunkApiConfig + >, + ): AsyncThunkSliceReducerDefinition< + State, + ThunkArg, + Returned, + CurriedThunkApiConfig + > + < + Returned, + ThunkArg, + ThunkApiConfig extends PreventCircular = {}, + >( + payloadCreator: AsyncThunkPayloadCreator< + Returned, + ThunkArg, + ThunkApiConfig + >, + config?: AsyncThunkSliceReducerConfig< + State, + ThunkArg, + Returned, + ThunkApiConfig + >, + ): AsyncThunkSliceReducerDefinition + withTypes< + ThunkApiConfig extends PreventCircular, + >(): AsyncThunkCreator< + State, + OverrideThunkApiConfigs + > +} + +export const asyncThunkCreator: ReducerCreator = { + type: ReducerType.asyncThunk, + create: /* @__PURE__ */ (() => { + function asyncThunk( + payloadCreator: AsyncThunkPayloadCreator, + config: AsyncThunkSliceReducerConfig, + ): AsyncThunkSliceReducerDefinition { + return { + _reducerDefinitionType: ReducerType.asyncThunk, + payloadCreator, + ...config, + } + } + asyncThunk.withTypes = () => asyncThunk + return asyncThunk as AsyncThunkCreator + })(), + handle({ type }, definition, context) { + const { payloadCreator, fulfilled, pending, rejected, settled, options } = + definition + const thunk = createAsyncThunk(type, payloadCreator, options as any) + context.exposeAction(thunk) + + if (fulfilled) { + context.addCase(thunk.fulfilled, fulfilled) + } + if (pending) { + context.addCase(thunk.pending, pending) + } + if (rejected) { + context.addCase(thunk.rejected, rejected) + } + if (settled) { + context.addMatcher(thunk.settled, settled) + } + + context.exposeCaseReducer({ + fulfilled: fulfilled || noop, + pending: pending || noop, + rejected: rejected || noop, + settled: settled || noop, + }) + }, +} + +function noop() {} diff --git a/packages/toolkit/src/createReducer.ts b/packages/toolkit/src/createReducer.ts index 564a1f13b0..34a56a22d7 100644 --- a/packages/toolkit/src/createReducer.ts +++ b/packages/toolkit/src/createReducer.ts @@ -152,14 +152,7 @@ export function createReducer>( let [actionsMap, finalActionMatchers, finalDefaultCaseReducer] = executeReducerBuilderCallback(mapOrBuilderCallback) - // Ensure the initial state gets frozen either way (if draftable) - let getInitialState: () => S - if (isStateFunction(initialState)) { - getInitialState = () => freezeDraftable(initialState()) - } else { - const frozenInitialState = freezeDraftable(initialState) - getInitialState = () => frozenInitialState - } + const getInitialState = makeGetInitialState(initialState) function reducer(state = getInitialState(), action: any): S { let caseReducers = [ @@ -219,3 +212,17 @@ export function createReducer>( return reducer as ReducerWithInitialState } + +export function makeGetInitialState>( + initialState: S | (() => S), +) { + // Ensure the initial state gets frozen either way (if draftable) + let getInitialState: () => S + if (isStateFunction(initialState)) { + getInitialState = () => freezeDraftable(initialState()) + } else { + const frozenInitialState = freezeDraftable(initialState) + getInitialState = () => frozenInitialState + } + return getInitialState +} diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 1d4f3e3712..f34f78a2d5 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -1,5 +1,9 @@ import type { Action, Reducer, UnknownAction } from 'redux' import type { Selector } from 'reselect' +import type { + AsyncThunkCreator, + AsyncThunkCreatorExposes, +} from './asyncThunkCreator' import type { InjectConfig } from './combineSlices' import type { ActionCreatorWithoutPayload, @@ -9,35 +13,323 @@ import type { _ActionCreatorWithPreparedPayload, } from './createAction' import { createAction } from './createAction' -import type { - AsyncThunk, - AsyncThunkConfig, - AsyncThunkOptions, - AsyncThunkPayloadCreator, - OverrideThunkApiConfigs, -} from './createAsyncThunk' import { createAsyncThunk as _createAsyncThunk } from './createAsyncThunk' import type { ActionMatcherDescriptionCollection, CaseReducer, ReducerWithInitialState, } from './createReducer' -import { createReducer } from './createReducer' +import { createReducer, makeGetInitialState } from './createReducer' import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders' import { executeReducerBuilderCallback } from './mapBuilders' -import type { Id, TypeGuard } from './tsHelpers' +import type { + Id, + Increment, + IsAny, + OverloadedReturnType, + TypeGuard, + UnionToIntersection, +} from './tsHelpers' import { getOrInsertComputed } from './utils' -const asyncThunkSymbol = /* @__PURE__ */ Symbol.for( - 'rtk-slice-createasyncthunk', -) -// type is annotated because it's too long to infer -export const asyncThunkCreator: { - [asyncThunkSymbol]: typeof _createAsyncThunk -} = { - [asyncThunkSymbol]: _createAsyncThunk, +export enum ReducerType { + reducer = 'reducer', + reducerWithPrepare = 'reducerWithPrepare', + asyncThunk = 'asyncThunk', +} + +export type RegisteredReducerType = keyof SliceReducerCreators< + any, + any, + any, + any +> + +export type ReducerDefinition< + T extends RegisteredReducerType = RegisteredReducerType, +> = { + _reducerDefinitionType: T +} + +export type ReducerCreatorEntry< + Create, + Exposes extends { + actions?: Record + caseReducers?: Record + } = {}, +> = { + create: Create + actions: Exposes extends { actions: NonNullable } + ? Exposes['actions'] + : {} + caseReducers: Exposes extends { caseReducers: NonNullable } + ? Exposes['caseReducers'] + : {} +} + +export type CreatorCaseReducers = + | Record + | SliceCaseReducers + +export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, +> { + [ReducerType.reducer]: ReducerCreatorEntry< + { + ( + caseReducer: CaseReducer, + ): CaseReducerDefinition + ( + caseReducer: CaseReducer>, + ): CaseReducerDefinition> + }, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends CaseReducer< + State, + any + > + ? ActionCreatorForCaseReducer< + CaseReducers[ReducerName], + SliceActionType + > + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends CaseReducer< + State, + any + > + ? CaseReducers[ReducerName] + : never + } + } + > + [ReducerType.reducerWithPrepare]: ReducerCreatorEntry< + >( + prepare: Prepare, + reducer: CaseReducer< + State, + ReturnType<_ActionCreatorWithPreparedPayload> + >, + ) => PreparedCaseReducerDefinition, + { + actions: { + [ReducerName in keyof CaseReducers as ReducerName]: CaseReducers[ReducerName] extends CaseReducerWithPrepare< + State, + any + > + ? CaseReducers[ReducerName] extends { prepare: any } + ? ActionCreatorForCaseReducerWithPrepare< + CaseReducers[ReducerName], + SliceActionType + > + : never + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends CaseReducerWithPrepare< + State, + any + > + ? CaseReducers[ReducerName] extends { reducer: infer Reducer } + ? Reducer + : never + : never + } + } + > + [ReducerType.asyncThunk]: ReducerCreatorEntry< + AsyncThunkCreator, + AsyncThunkCreatorExposes + > +} + +export type ReducerCreators< + State, + Name extends string = string, + ReducerPath extends string = Name, + CreatorMap extends Record = {}, +> = { + reducer: SliceReducerCreators< + State, + any, + Name, + ReducerPath + >[ReducerType.reducer]['create'] + preparedReducer: SliceReducerCreators< + State, + any, + Name, + ReducerPath + >[ReducerType.reducerWithPrepare]['create'] +} & { + [CreatorName in keyof CreatorMap as SliceReducerCreators< + State, + never, + Name, + ReducerPath + >[CreatorMap[CreatorName]]['create'] extends never + ? never + : CreatorName]: SliceReducerCreators< + State, + never, + Name, + ReducerPath + >[CreatorMap[CreatorName]]['create'] +} + +interface InternalReducerHandlingContext { + sliceCaseReducersByType: Record> + sliceMatchers: ActionMatcherDescriptionCollection + + sliceCaseReducersByName: Record + actionCreators: Record +} + +export interface ReducerHandlingContext { + /** + * Adds a case reducer to handle a single action type. + * @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: ActionCreator, + reducer: CaseReducer>, + ): ReducerHandlingContext + /** + * Adds a case reducer to handle a single action type. + * @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: Type, + reducer: CaseReducer, + ): ReducerHandlingContext + + /** + * Allows you to match 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. + * @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. + * + */ + addMatcher( + matcher: TypeGuard, + reducer: CaseReducer, + ): ReducerHandlingContext + /** + * Add an action to be exposed under the final `slice.actions[reducerName]` key. + * + * Should only be called once per handler. + * + * @param actionCreator The action to expose. + * @example + * context.exposeAction(createAction(type)); + * + * export const { addPost } = slice.actions + * + * dispatch(addPost(post)) + */ + exposeAction(actionCreator: unknown): ReducerHandlingContext + + /** + * Add a case reducer to be exposed under the final `slice.caseReducers[reducerName]` key. + * + * Should only be called once per handler. + * + * @param reducer The reducer to expose. + * @example + * context.exposeCaseReducer((state, action: PayloadAction) => { + * state.push(action.payload) + * }) + * + * slice.caseReducers.addPost([], addPost(post)) + */ + exposeCaseReducer(reducer: unknown): ReducerHandlingContext + + /** + * Provides access to the initial state value given to the slice. + * If a lazy state initializer was provided, it will be called and a fresh value returned. + */ + getInitialState(): State + + /** + * Tries to select the slice's state from a possible root state shape, using `reducerPath`. + * Throws an error if slice's state is not found. + * + * *Note that only the original `reducerPath` option is used - if a different `reducerPath` is used when injecting, this will not be reflected.* + */ + selectSlice(state: Record): State +} + +export interface ReducerDetails { + /** The name of the slice */ + sliceName: string + /** The reducerPath option passed for the slice. Defaults to `sliceName` if not provided. */ + reducerPath: string + /** The key the reducer was defined under */ + reducerName: string + /** The predefined action type, i.e. `${sliceName}/${reducerName}` */ + type: string } +type DefinitionFromValue< + T extends object, + Type extends RegisteredReducerType, + RecursionDepth extends number = 0, +> = RecursionDepth extends 5 + ? never + : IsAny< + T, + never, + | Extract> + | { + [K in keyof T]-?: T[K] extends object + ? DefinitionFromValue> + : never + }[keyof T] + | (T extends (...args: any[]) => object + ? DefinitionFromValue< + OverloadedReturnType, + Type, + Increment + > + : never) + > + +type ReducerDefinitionsForType = { + [CreatorType in RegisteredReducerType]: DefinitionFromValue< + SliceReducerCreators[CreatorType]['create'], + Type + > +}[RegisteredReducerType] + +export type ReducerCreator = { + type: Type + create: SliceReducerCreators[Type]['create'] +} & (ReducerDefinitionsForType extends never + ? { + handle?( + details: ReducerDetails, + definition: unknown, + context: ReducerHandlingContext, + ): void + } + : { + handle( + details: ReducerDetails, + definition: ReducerDefinitionsForType, + context: ReducerHandlingContext, + ): void + }) + type InjectIntoConfig = InjectConfig & { reducerPath?: NewReducerPath } @@ -49,7 +341,7 @@ type InjectIntoConfig = InjectConfig & { */ export interface Slice< State = any, - CaseReducers extends SliceCaseReducers = SliceCaseReducers, + CaseReducers extends CreatorCaseReducers = SliceCaseReducers, Name extends string = string, ReducerPath extends string = Name, Selectors extends SliceSelectors = SliceSelectors, @@ -73,13 +365,13 @@ export interface Slice< * Action creators for the types of actions that are handled by the slice * reducer. */ - actions: CaseReducerActions + actions: CaseReducerActions /** * The individual case reducer functions that were passed in the `reducers` parameter. * This enables reuse and testing if they were defined inline when calling `createSlice`. */ - caseReducers: SliceDefinedCaseReducers + caseReducers: SliceDefinedCaseReducers /** * Provides access to the initial state value given to the slice. @@ -105,7 +397,7 @@ export interface Slice< * Equivalent to `slice.getSelectors((state: RootState) => state[slice.reducerPath])`. */ get selectors(): Id< - SliceDefinedSelectors + SliceDefinedSelectors> > /** @@ -127,7 +419,7 @@ export interface Slice< * * Will throw an error if slice is not found. */ - selectSlice(state: { [K in ReducerPath]: State }): State + selectSlice(state: Record): State } /** @@ -137,7 +429,7 @@ export interface Slice< */ type InjectedSlice< State = any, - CaseReducers extends SliceCaseReducers = SliceCaseReducers, + CaseReducers extends CreatorCaseReducers = SliceCaseReducers, Name extends string = string, ReducerPath extends string = Name, Selectors extends SliceSelectors = SliceSelectors, @@ -178,6 +470,26 @@ type InjectedSlice< selectSlice(state: { [K in ReducerPath]?: State | undefined }): State } +type CreatorCallback< + State, + Name extends string, + ReducerPath extends string, + CreatorMap extends Record, +> = ( + create: ReducerCreators, +) => Record + +type GetCaseReducers< + State, + Name extends string, + ReducerPath extends string, + CreatorMap extends Record, + CR extends SliceCaseReducers | CreatorCallback, +> = + CR extends CreatorCallback + ? ReturnType + : CR + /** * Options for `createSlice()`. * @@ -185,10 +497,18 @@ type InjectedSlice< */ export interface CreateSliceOptions< State = any, - CR extends SliceCaseReducers = SliceCaseReducers, + CR extends + | SliceCaseReducers + | CreatorCallback< + State, + Name, + ReducerPath, + CreatorMap + > = SliceCaseReducers, Name extends string = string, ReducerPath extends string = Name, Selectors extends SliceSelectors = SliceSelectors, + CreatorMap extends Record = {}, > { /** * The slice's name. Used to namespace the generated action types. @@ -210,9 +530,7 @@ export interface CreateSliceOptions< * functions. For every action type, a matching action creator will be * generated using `createAction()`. */ - reducers: - | ValidateSliceCaseReducers - | ((creators: ReducerCreators) => CR) + reducers: ValidateSliceCaseReducers /** * A callback that receives a *builder* object to define @@ -264,16 +582,6 @@ createSlice({ selectors?: Selectors } -export enum ReducerType { - reducer = 'reducer', - reducerWithPrepare = 'reducerWithPrepare', - asyncThunk = 'asyncThunk', -} - -type ReducerDefinition = { - _reducerDefinitionType: T -} - export type CaseReducerDefinition< S = any, A extends Action = UnknownAction, @@ -289,144 +597,33 @@ export type CaseReducerWithPrepare = { prepare: PrepareAction } -export interface CaseReducerWithPrepareDefinition< +export type CaseReducerWithPrepareDefinition< State, Action extends PayloadAction, -> extends CaseReducerWithPrepare, - ReducerDefinition {} - -type 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']> - > - settled?: CaseReducer< - State, - ReturnType< - AsyncThunk['rejected' | 'fulfilled'] - > - > - options?: AsyncThunkOptions -} - -type AsyncThunkSliceReducerDefinition< - State, - ThunkArg extends any, - Returned = unknown, - ThunkApiConfig extends AsyncThunkConfig = {}, -> = AsyncThunkSliceReducerConfig & - 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] -} +> = CaseReducerWithPrepare & + ReducerDefinition -interface AsyncThunkCreator< +export type PreparedCaseReducerDefinition< State, - CurriedThunkApiConfig extends - PreventCircular = PreventCircular, -> { - ( - payloadCreator: AsyncThunkPayloadCreator< - Returned, - ThunkArg, - CurriedThunkApiConfig - >, - config?: AsyncThunkSliceReducerConfig< - State, - ThunkArg, - Returned, - CurriedThunkApiConfig - >, - ): AsyncThunkSliceReducerDefinition< - State, - ThunkArg, - Returned, - CurriedThunkApiConfig - > - < - Returned, - ThunkArg, - ThunkApiConfig extends PreventCircular = {}, - >( - payloadCreator: AsyncThunkPayloadCreator< - Returned, - ThunkArg, - ThunkApiConfig - >, - config?: AsyncThunkSliceReducerConfig< - State, - ThunkArg, - Returned, - ThunkApiConfig - >, - ): AsyncThunkSliceReducerDefinition - withTypes< - ThunkApiConfig extends PreventCircular, - >(): AsyncThunkCreator< + Prepare extends PrepareAction, +> = ReducerDefinition & { + prepare: Prepare + reducer: CaseReducer< State, - OverrideThunkApiConfigs + ReturnType<_ActionCreatorWithPreparedPayload> > } -export interface ReducerCreators { - reducer( - caseReducer: CaseReducer, - ): CaseReducerDefinition - 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 = - | Record - | Record< - string, - | CaseReducer> - | CaseReducerWithPrepare> - > +export type SliceCaseReducers = Record< + string, + | CaseReducer> + | CaseReducerWithPrepare> +> /** * The type describing a slice's `selectors` option. @@ -435,44 +632,37 @@ export type SliceSelectors = { [K: string]: (sliceState: State, ...args: any[]) => any } -type SliceActionType< +export type SliceActionType< SliceName extends string, ActionName extends keyof any, > = ActionName extends string | number ? `${SliceName}/${ActionName}` : string +type ConvertNeverKeysToUnknown = T extends any + ? { [K in keyof T]: T[K] extends never ? unknown : T[K] } + : never + /** * Derives the slice's `actions` property from the `reducers` options * * @public */ export type CaseReducerActions< - CaseReducers extends SliceCaseReducers, + CaseReducers extends CreatorCaseReducers, SliceName extends string, -> = { - [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 -} + ReducerPath extends string = SliceName, + State = any, +> = Id< + UnionToIntersection< + ConvertNeverKeysToUnknown< + SliceReducerCreators< + State, + CaseReducers, + SliceName, + ReducerPath + >[RegisteredReducerType]['actions'] + > + > +> /** * Get a `PayloadActionCreator` type for a passed `CaseReducerWithPrepare` @@ -504,22 +694,23 @@ type ActionCreatorForCaseReducer = CR extends ( * * @internal */ -type SliceDefinedCaseReducers> = { - [Type in keyof CaseReducers]: CaseReducers[Type] extends infer Definition - ? Definition extends AsyncThunkSliceReducerDefinition - ? Id< - Pick< - Required, - 'fulfilled' | 'rejected' | 'pending' | 'settled' - > - > - : Definition extends { - reducer: infer Reducer - } - ? Reducer - : Definition - : never -} +type SliceDefinedCaseReducers< + CaseReducers extends CreatorCaseReducers, + SliceName extends string = string, + ReducerPath extends string = SliceName, + State = any, +> = Id< + UnionToIntersection< + ConvertNeverKeysToUnknown< + SliceReducerCreators< + State, + CaseReducers, + SliceName, + ReducerPath + >[RegisteredReducerType]['caseReducers'] + > + > +> type RemappedSelector = S extends Selector @@ -556,7 +747,7 @@ type SliceDefinedSelectors< */ export type ValidateSliceCaseReducers< S, - ACR extends SliceCaseReducers, + ACR extends SliceCaseReducers | CreatorCallback, > = ACR & { [T in keyof ACR]: ACR[T] extends { reducer(s: S, action?: infer A): any @@ -571,17 +762,118 @@ function getType(slice: string, actionKey: string): string { return `${slice}/${actionKey}` } -interface BuildCreateSliceConfig { - creators?: { - asyncThunk?: typeof asyncThunkCreator +export const reducerCreator: ReducerCreator = { + type: ReducerType.reducer, + create(caseReducer: 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, + ) + }, + handle({ type }, reducer, context) { + context + .addCase(type, reducer as any) + .exposeCaseReducer(reducer) + .exposeAction(createAction(type)) + }, +} + +export const preparedReducerCreator: ReducerCreator = + { + type: ReducerType.reducerWithPrepare, + create(prepare, reducer) { + return { + _reducerDefinitionType: ReducerType.reducerWithPrepare, + prepare, + reducer, + } + }, + handle({ type }, { prepare, reducer }, context) { + context + .addCase(type, reducer) + .exposeCaseReducer(reducer) + .exposeAction(createAction(type, prepare)) + }, } + +const isCreatorCallback = ( + reducers: unknown, +): reducers is CreatorCallback => + typeof reducers === 'function' + +interface BuildCreateSliceConfig< + CreatorMap extends Record, +> { + creators?: { + [Name in keyof CreatorMap]: Name extends 'reducer' | 'preparedReducer' + ? never + : ReducerCreator + } & { asyncThunk?: ReducerCreator } } -export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { - const cAT = creators?.asyncThunk?.[asyncThunkSymbol] +export function buildCreateSlice< + CreatorMap extends Record = {}, +>({ + creators: creatorMap = {} as NonNullable< + BuildCreateSliceConfig['creators'] + >, +}: BuildCreateSliceConfig = {}) { + const creators: Record< + string, + ReducerCreator['create'] + > = { + reducer: reducerCreator.create, + preparedReducer: preparedReducerCreator.create, + } + const handlers: Partial< + Record< + RegisteredReducerType, + ReducerCreator['handle'] + > + > = { + [ReducerType.reducer]: reducerCreator.handle, + [ReducerType.reducerWithPrepare]: preparedReducerCreator.handle, + } + + for (const [name, creator] of Object.entries< + ReducerCreator + >(creatorMap as any)) { + if (name === 'reducer' || name === 'preparedReducer') { + throw new Error('Cannot use reserved creator name: ' + name) + } + if ( + creator.type === ReducerType.reducer || + creator.type === ReducerType.reducerWithPrepare + ) { + throw new Error( + `Cannot use reserved creator type: ${String(creator.type)}`, + ) + } else if ( + name === 'asyncThunk' && + creator.type !== ReducerType.asyncThunk + ) { + throw new Error( + "If provided, `asyncThunk` creator must be `asyncThunkCreator` from '@reduxjs/toolkit'", + ) + } + creators[name] = creator.create + if ('handle' in creator) { + handlers[creator.type] = creator.handle + } + } return function createSlice< State, - CaseReducers extends SliceCaseReducers, + CaseReducers extends + | SliceCaseReducers + | CreatorCallback, Name extends string, Selectors extends SliceSelectors, ReducerPath extends string = Name, @@ -591,9 +883,16 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { CaseReducers, Name, ReducerPath, - Selectors + Selectors, + CreatorMap >, - ): Slice { + ): Slice< + State, + GetCaseReducers, + Name, + ReducerPath, + Selectors + > { const { name, reducerPath = name as unknown as ReducerPath } = options if (!name) { throw new Error('`name` is a required option for createSlice') @@ -610,79 +909,123 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { } } - const reducers = - (typeof options.reducers === 'function' - ? options.reducers(buildReducerCreators()) - : options.reducers) || {} - - const reducerNames = Object.keys(reducers) + const getInitialState = makeGetInitialState(options.initialState) - const context: ReducerHandlingContext = { + const internalContext: InternalReducerHandlingContext = { sliceCaseReducersByName: {}, sliceCaseReducersByType: {}, actionCreators: {}, sliceMatchers: [], } - const contextMethods: ReducerHandlingContextMethods = { - addCase( - typeOrActionCreator: string | TypedActionCreator, - reducer: CaseReducer, - ) { - const type = - typeof typeOrActionCreator === 'string' - ? typeOrActionCreator - : typeOrActionCreator.type - if (!type) { + function getContext({ reducerName }: ReducerDetails) { + const context: ReducerHandlingContext = { + addCase( + typeOrActionCreator: string | TypedActionCreator, + reducer: CaseReducer, + ) { + const type = + typeof typeOrActionCreator === 'string' + ? typeOrActionCreator + : typeOrActionCreator.type + if (!type) { + throw new Error( + '`context.addCase` cannot be called with an empty action type', + ) + } + if (type in internalContext.sliceCaseReducersByType) { + throw new Error( + '`context.addCase` cannot be called with two reducers for the same action type: ' + + type, + ) + } + internalContext.sliceCaseReducersByType[type] = reducer + return context + }, + addMatcher(matcher, reducer) { + internalContext.sliceMatchers.push({ matcher, reducer }) + return context + }, + exposeAction(actionCreator) { + if (reducerName in internalContext.actionCreators) { + throw new Error( + 'context.exposeAction cannot be called twice for the same reducer definition: ' + + reducerName, + ) + } + internalContext.actionCreators[reducerName] = actionCreator + return context + }, + exposeCaseReducer(reducer) { + if (reducerName in internalContext.sliceCaseReducersByName) { + throw new Error( + 'context.exposeCaseReducer cannot be called twice for the same reducer definition: ' + + reducerName, + ) + } + internalContext.sliceCaseReducersByName[reducerName] = reducer + return context + }, + getInitialState, + selectSlice(state) { + const sliceState = state[reducerPath] + if (typeof sliceState === 'undefined') { + throw new Error( + `Could not find "${name}" slice in state. In order for slice creators to use \`context.selectSlice\`, the slice must be nested in the state under its reducerPath: "${reducerPath}"`, + ) + } + return sliceState as State + }, + } + return context + } + + if (isCreatorCallback(options.reducers)) { + const reducers = options.reducers(creators as any) + for (const [reducerName, reducerDefinition] of Object.entries(reducers)) { + const { _reducerDefinitionType: type } = reducerDefinition + if (typeof type === 'undefined') { throw new Error( - '`context.addCase` cannot be called with an empty action type', + 'Please use reducer creators passed to callback. Each reducer definition must have a `_reducerDefinitionType` property indicating which handler to use.', ) } - if (type in context.sliceCaseReducersByType) { - throw new Error( - '`context.addCase` cannot be called with two reducers for the same action type: ' + - type, - ) + const handle = handlers[type] + if (!handle) { + throw new Error(`Unsupported reducer type: ${String(type)}`) } - context.sliceCaseReducersByType[type] = reducer - return contextMethods - }, - addMatcher(matcher, reducer) { - context.sliceMatchers.push({ matcher, reducer }) - return contextMethods - }, - exposeAction(name, actionCreator) { - context.actionCreators[name] = actionCreator - return contextMethods - }, - exposeCaseReducer(name, reducer) { - context.sliceCaseReducersByName[name] = reducer - return contextMethods - }, - } - - reducerNames.forEach((reducerName) => { - const reducerDefinition = reducers[reducerName] - const reducerDetails: ReducerDetails = { - reducerName, - type: getType(name, reducerName), - createNotation: typeof options.reducers === 'function', - } - if (isAsyncThunkSliceReducerDefinition(reducerDefinition)) { - handleThunkCaseReducerDefinition( + const reducerDetails: ReducerDetails = { + sliceName: name, + reducerName, + reducerPath, + type: getType(name, reducerName), + } + handle( reducerDetails, - reducerDefinition, - contextMethods, - cAT, + reducerDefinition as any, + getContext(reducerDetails), ) - } else { - handleNormalReducerDefinition( + } + } else { + for (const [reducerName, reducerDefinition] of Object.entries( + options.reducers as SliceCaseReducers, + )) { + const reducerDetails: ReducerDetails = { + sliceName: name, + reducerName, + reducerPath, + type: getType(name, reducerName), + } + const { handle } = + 'reducer' in reducerDefinition + ? preparedReducerCreator + : reducerCreator + handle( reducerDetails, reducerDefinition as any, - contextMethods, + getContext(reducerDetails), ) } - }) + } function buildReducer() { if (process.env.NODE_ENV !== 'production') { @@ -703,14 +1046,14 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { const finalCaseReducers = { ...extraReducers, - ...context.sliceCaseReducersByType, + ...internalContext.sliceCaseReducersByType, } return createReducer(options.initialState, (builder) => { for (let key in finalCaseReducers) { - builder.addCase(key, finalCaseReducers[key] as CaseReducer) + builder.addCase(key, finalCaseReducers[key] as CaseReducer) } - for (let sM of context.sliceMatchers) { + for (let sM of internalContext.sliceMatchers) { builder.addMatcher(sM.matcher, sM.reducer) } for (let m of actionMatchers) { @@ -740,17 +1083,17 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { return _reducer(state, action) } - function getInitialState() { - if (!_reducer) _reducer = buildReducer() - - return _reducer.getInitialState() - } - function makeSelectorProps( reducerPath: CurrentReducerPath, injected = false, ): Pick< - Slice, + Slice< + State, + GetCaseReducers, + Name, + CurrentReducerPath, + Selectors + >, 'getSelectors' | 'selectors' | 'selectSlice' | 'reducerPath' > { function selectSlice(state: { [K in CurrentReducerPath]: State }) { @@ -800,15 +1143,23 @@ export function buildCreateSlice({ creators }: BuildCreateSliceConfig = {}) { } } - const slice: Slice = { + const slice: Slice< + State, + GetCaseReducers, + Name, + ReducerPath, + Selectors + > = { name, reducer, - actions: context.actionCreators as any, - caseReducers: context.sliceCaseReducersByName as any, + actions: internalContext.actionCreators as any, + caseReducers: internalContext.sliceCaseReducersByName as any, getInitialState, ...makeSelectorProps(reducerPath), - injectInto(injectable, { reducerPath: pathOpt, ...config } = {}) { - const newReducerPath = pathOpt ?? reducerPath + injectInto( + injectable, + { reducerPath: newReducerPath = reducerPath, ...config } = {}, + ) { injectable.inject({ reducerPath: newReducerPath, reducer }, config) return { ...slice, @@ -851,225 +1202,4 @@ function wrapSelector>( * * @public */ -export const createSlice = /* @__PURE__ */ buildCreateSlice() - -interface ReducerHandlingContext { - sliceCaseReducersByName: Record< - string, - | CaseReducer - | Pick< - AsyncThunkSliceReducerDefinition, - 'fulfilled' | 'rejected' | 'pending' | 'settled' - > - > - sliceCaseReducersByType: Record> - sliceMatchers: ActionMatcherDescriptionCollection - actionCreators: Record -} - -interface ReducerHandlingContextMethods { - /** - * Adds a case reducer to handle a single action type. - * @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: ActionCreator, - reducer: CaseReducer>, - ): ReducerHandlingContextMethods - /** - * Adds a case reducer to handle a single action type. - * @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: Type, - reducer: CaseReducer, - ): ReducerHandlingContextMethods - - /** - * Allows you to match 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`. - * @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. - * - */ - addMatcher( - matcher: TypeGuard, - reducer: CaseReducer, - ): ReducerHandlingContextMethods - /** - * Add an action to be exposed under the final `slice.actions` key. - * @param name The key to be exposed as. - * @param actionCreator The action to expose. - * @example - * context.exposeAction("addPost", createAction("addPost")); - * - * export const { addPost } = slice.actions - * - * dispatch(addPost(post)) - */ - exposeAction( - name: string, - actionCreator: Function, - ): ReducerHandlingContextMethods - /** - * Add a case reducer to be exposed under the final `slice.caseReducers` key. - * @param name The key to be exposed as. - * @param reducer The reducer to expose. - * @example - * context.exposeCaseReducer("addPost", (state, action: PayloadAction) => { - * state.push(action.payload) - * }) - * - * slice.caseReducers.addPost([], addPost(post)) - */ - exposeCaseReducer( - name: string, - reducer: - | CaseReducer - | Pick< - AsyncThunkSliceReducerDefinition, - 'fulfilled' | 'rejected' | 'pending' | 'settled' - >, - ): ReducerHandlingContextMethods -} - -interface ReducerDetails { - /** The key the reducer was defined under */ - reducerName: string - /** The predefined action type, i.e. `${slice.name}/${reducerName}` */ - type: string - /** Whether create. notation was used when defining reducers */ - createNotation: boolean -} - -function buildReducerCreators(): ReducerCreators { - function asyncThunk( - payloadCreator: AsyncThunkPayloadCreator, - config: AsyncThunkSliceReducerConfig, - ): AsyncThunkSliceReducerDefinition { - return { - _reducerDefinitionType: ReducerType.asyncThunk, - payloadCreator, - ...config, - } - } - asyncThunk.withTypes = () => asyncThunk - return { - reducer(caseReducer: 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: ReducerHandlingContextMethods, -) { - 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 - .addCase(type, caseReducer) - .exposeCaseReducer(reducerName, caseReducer) - .exposeAction( - 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: ReducerHandlingContextMethods, - cAT: typeof _createAsyncThunk | undefined, -) { - if (!cAT) { - throw new Error( - 'Cannot use `create.asyncThunk` in the built-in `createSlice`. ' + - 'Use `buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })` to create a customised version of `createSlice`.', - ) - } - const { payloadCreator, fulfilled, pending, rejected, settled, options } = - reducerDefinition - const thunk = cAT(type, payloadCreator, options as any) - context.exposeAction(reducerName, thunk) - - if (fulfilled) { - context.addCase(thunk.fulfilled, fulfilled) - } - if (pending) { - context.addCase(thunk.pending, pending) - } - if (rejected) { - context.addCase(thunk.rejected, rejected) - } - if (settled) { - context.addMatcher(thunk.settled, settled) - } - - context.exposeCaseReducer(reducerName, { - fulfilled: fulfilled || noop, - pending: pending || noop, - rejected: rejected || noop, - settled: settled || noop, - }) -} - -function noop() {} +export const createSlice = /*@__PURE__*/ buildCreateSlice() diff --git a/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts index f6b0c7bd22..8694762af9 100644 --- a/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts +++ b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts @@ -1,15 +1,10 @@ import { createEntityAdapter, createSlice } from '../..' -import type { - PayloadAction, - Slice, - SliceCaseReducers, - UnknownAction, -} from '../..' -import type { EntityId, EntityState, IdSelector } from '../models' +import type { PayloadAction, SliceCaseReducers, UnknownAction } from '../..' +import type { EntityId, IdSelector } from '../models' import type { BookModel } from './fixtures/book' describe('Entity Slice Enhancer', () => { - let slice: Slice> + let slice: ReturnType> beforeEach(() => { const indieSlice = entitySliceEnhancer({ diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 65291ab83b..0e06a93389 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -65,9 +65,11 @@ export { // js createSlice, buildCreateSlice, - asyncThunkCreator, + reducerCreator, + preparedReducerCreator, ReducerType, } from './createSlice' +export { asyncThunkCreator } from './asyncThunkCreator' export type { // types @@ -75,11 +77,22 @@ export type { Slice, CaseReducerActions, SliceCaseReducers, + CreatorCaseReducers, ValidateSliceCaseReducers, CaseReducerWithPrepare, ReducerCreators, SliceSelectors, + SliceReducerCreators, + ReducerDefinition, + ReducerCreatorEntry, + ReducerCreator, + ReducerDetails, + ReducerHandlingContext, + SliceActionType, + CaseReducerDefinition, + PreparedCaseReducerDefinition, } from './createSlice' +export type { AsyncThunkSliceReducerDefinition } from './asyncThunkCreator' export type { ActionCreatorInvariantMiddlewareOptions } from './actionCreatorInvariantMiddleware' export { createActionCreatorInvariantMiddleware } from './actionCreatorInvariantMiddleware' export { diff --git a/packages/toolkit/src/tests/asyncThunkCreator.test-d.ts b/packages/toolkit/src/tests/asyncThunkCreator.test-d.ts new file mode 100644 index 0000000000..d4cc476f28 --- /dev/null +++ b/packages/toolkit/src/tests/asyncThunkCreator.test-d.ts @@ -0,0 +1,240 @@ +import type { + ThunkDispatch, + SerializedError, + AsyncThunk, + CaseReducer, +} from '@reduxjs/toolkit' +import { + asyncThunkCreator, + buildCreateSlice, + isRejected, + configureStore, +} from '@reduxjs/toolkit' + +describe('type tests', () => { + test('reducer callback', () => { + interface TestState { + foo: string + } + + interface TestArg { + test: string + } + + interface TestReturned { + payload: string + } + + interface TestReject { + cause: string + } + + const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, + }) + + const slice = createAppSlice({ + 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 { + testInfer: create.asyncThunk( + function payloadCreator(arg: TestArg, api) { + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + testExplicitType: create.asyncThunk< + TestReturned, + TestArg, + { + rejectValue: TestReject + } + >( + function payloadCreator(arg, api) { + // here would be a circular reference + expectTypeOf(api.getState()).toBeUnknown() + // here would be a circular reference + expectTypeOf(api.dispatch).toMatchTypeOf< + ThunkDispatch + >() + + // so you need to cast inside instead + const getState = api.getState as () => StoreState + const dispatch = api.dispatch as StoreDispatch + + expectTypeOf(arg).toEqualTypeOf() + + expectTypeOf(api.rejectWithValue).toMatchTypeOf< + (value: TestReject) => any + >() + + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + testPreTyped: preTypedAsyncThunk( + function payloadCreator(arg: TestArg, api) { + expectTypeOf(api.rejectWithValue).toMatchTypeOf< + (value: TestReject) => any + >() + + return Promise.resolve({ payload: 'foo' }) + }, + { + pending(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + }, + fulfilled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf() + }, + rejected(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + }, + settled(state, action) { + expectTypeOf(state).toEqualTypeOf() + + expectTypeOf(action.meta.arg).toEqualTypeOf() + + if (isRejected(action)) { + expectTypeOf(action.error).toEqualTypeOf() + + expectTypeOf(action.payload).toEqualTypeOf< + TestReject | undefined + >() + } else { + expectTypeOf(action.payload).toEqualTypeOf() + } + }, + }, + ), + } + }, + }) + + const store = configureStore({ reducer: { test: slice.reducer } }) + + type StoreState = ReturnType + + type StoreDispatch = typeof store.dispatch + + expectTypeOf(slice.actions.testInfer).toEqualTypeOf< + AsyncThunk + >() + + expectTypeOf(slice.actions.testExplicitType).toEqualTypeOf< + AsyncThunk + >() + + type TestInferThunk = AsyncThunk + + expectTypeOf(slice.caseReducers.testInfer.pending).toEqualTypeOf< + CaseReducer> + >() + + expectTypeOf(slice.caseReducers.testInfer.fulfilled).toEqualTypeOf< + CaseReducer> + >() + + expectTypeOf(slice.caseReducers.testInfer.rejected).toEqualTypeOf< + CaseReducer> + >() + }) +}) diff --git a/packages/toolkit/src/tests/asyncThunkCreator.test.ts b/packages/toolkit/src/tests/asyncThunkCreator.test.ts new file mode 100644 index 0000000000..13b5756585 --- /dev/null +++ b/packages/toolkit/src/tests/asyncThunkCreator.test.ts @@ -0,0 +1,265 @@ +import { + asyncThunkCreator, + buildCreateSlice, + createSlice, + configureStore, +} from '@reduxjs/toolkit' + +describe('asyncThunkCreator', () => { + describe('reducers definition with asyncThunks', () => { + it('is disabled by default', () => { + expect(() => + createSlice({ + name: 'test', + initialState: [] as any[], + // @ts-expect-error asyncThunk not in creators + reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `[TypeError: create.asyncThunk is not a function]`, + ) + }) + const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, + }) + 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]) + } + function settled(state: any[], action: any) { + state.push(['settledReducer', action]) + } + + test('successful thunk', async () => { + const slice = createAppSlice({ + name: 'test', + initialState: [] as any[], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + function payloadCreator(arg, api) { + return Promise.resolve('resolved payload') + }, + { pending, fulfilled, rejected, settled }, + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers()) + expect(store.getState()).toMatchObject([ + [ + 'pendingReducer', + { + type: 'test/thunkReducers/pending', + payload: undefined, + }, + ], + [ + 'fulfilledReducer', + { + type: 'test/thunkReducers/fulfilled', + payload: 'resolved payload', + }, + ], + [ + 'settledReducer', + { + type: 'test/thunkReducers/fulfilled', + payload: 'resolved payload', + }, + ], + ]) + }) + + test('rejected thunk', async () => { + const slice = createAppSlice({ + 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, settled }, + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers()) + expect(store.getState()).toMatchObject([ + [ + 'pendingReducer', + { + type: 'test/thunkReducers/pending', + payload: undefined, + }, + ], + [ + 'rejectedReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + }, + ], + [ + 'settledReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + }, + ], + ]) + }) + + test('with options', async () => { + const slice = createAppSlice({ + 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, + settled, + }, + ), + }), + }) + + const store = configureStore({ + reducer: slice.reducer, + }) + await store.dispatch(slice.actions.thunkReducers()) + expect(store.getState()).toMatchObject([ + [ + 'rejectedReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + meta: { condition: true }, + }, + ], + [ + 'settledReducer', + { + type: 'test/thunkReducers/rejected', + payload: undefined, + meta: { condition: true }, + }, + ], + ]) + }) + + test('has caseReducers for the asyncThunk', async () => { + const slice = createAppSlice({ + name: 'test', + initialState: [], + reducers: (create) => ({ + thunkReducers: create.asyncThunk( + function payloadCreator(arg, api) { + return Promise.resolve('resolved payload') + }, + { pending, fulfilled, settled }, + ), + }), + }) + + expect(slice.caseReducers.thunkReducers.pending).toBe(pending) + expect(slice.caseReducers.thunkReducers.fulfilled).toBe(fulfilled) + expect(slice.caseReducers.thunkReducers.settled).toBe(settled) + // 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[], + // @ts-expect-error + reducers: (create) => ({ + prepared: { + prepare: (p: string, m: number, e: { message: string }) => ({ + payload: p, + meta: m, + error: e, + }), + reducer: (state: any[], action: any) => { + state.push(action) + }, + }, + }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Please use reducer creators passed to callback. Each reducer definition must have a \`_reducerDefinitionType\` property indicating which handler to use.]`, + ) + }) + }) +}) diff --git a/packages/toolkit/src/tests/createEntityAdapter.test-d.ts b/packages/toolkit/src/tests/createEntityAdapter.test-d.ts index 0e8a4c54f4..4dc78b9501 100644 --- a/packages/toolkit/src/tests/createEntityAdapter.test-d.ts +++ b/packages/toolkit/src/tests/createEntityAdapter.test-d.ts @@ -114,9 +114,9 @@ describe('type tests', () => { createSlice({ name: 'test', initialState: adapter.getInitialState(), + // @ts-expect-error reducers: { addOne: adapter.addOne, - // @ts-expect-error addOne2: adapter2.addOne, }, }) @@ -140,8 +140,8 @@ describe('type tests', () => { createSlice({ name: 'test', initialState: { somethingElse: '' }, + // @ts-expect-error reducers: { - // @ts-expect-error addOne: adapter.addOne, }, }) diff --git a/packages/toolkit/src/tests/createSlice.test-d.ts b/packages/toolkit/src/tests/createSlice.test-d.ts index 8666497007..fd65e99f4e 100644 --- a/packages/toolkit/src/tests/createSlice.test-d.ts +++ b/packages/toolkit/src/tests/createSlice.test-d.ts @@ -6,15 +6,18 @@ import type { ActionCreatorWithPreparedPayload, ActionCreatorWithoutPayload, ActionReducerMapBuilder, - AsyncThunk, - CaseReducer, + CreatorCaseReducers, PayloadAction, PayloadActionCreator, Reducer, + ReducerCreator, + ReducerCreatorEntry, ReducerCreators, - SerializedError, + ReducerDefinition, + ReducerHandlingContext, + SliceActionType, SliceCaseReducers, - ThunkDispatch, + ThunkAction, UnknownAction, ValidateSliceCaseReducers, } from '@reduxjs/toolkit' @@ -25,10 +28,12 @@ import { createAction, createAsyncThunk, createSlice, - isRejected, + nanoid, } from '@reduxjs/toolkit' import { castDraft } from 'immer' +const toasterCreatorType = Symbol() + describe('type tests', () => { const counterSlice = createSlice({ name: 'counter', @@ -606,240 +611,41 @@ describe('type tests', () => { 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 - }>() + reducers: (create) => ({ + normalReducer: create.reducer((state, action) => { + expectTypeOf(state).toEqualTypeOf() - return { - normalReducer: create.reducer((state, action) => { - expectTypeOf(state).toEqualTypeOf() + expectTypeOf(action.payload).toBeString() + }), + optionalReducer: create.reducer((state, action) => { + expectTypeOf(state).toEqualTypeOf() - expectTypeOf(action.payload).toBeString() + expectTypeOf(action.payload).toEqualTypeOf() + }), + noActionReducer: create.reducer((state) => { + expectTypeOf(state).toEqualTypeOf() + }), + preparedReducer: create.preparedReducer( + (payload: string) => ({ + payload, + meta: 'meta' as const, + error: 'error' as const, }), - optionalReducer: create.reducer( - (state, action) => { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf() - }, - ), - noActionReducer: create.reducer((state) => { + (state, action) => { expectTypeOf(state).toEqualTypeOf() - }), - preparedReducer: create.preparedReducer( - (payload: string) => ({ - payload, - meta: 'meta' as const, - error: 'error' as const, - }), - (state, action) => { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.payload).toBeString() - - expectTypeOf(action.meta).toEqualTypeOf<'meta'>() - - expectTypeOf(action.error).toEqualTypeOf<'error'>() - }, - ), - testInferVoid: create.asyncThunk(() => {}, { - pending(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toBeVoid() - }, - fulfilled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toBeVoid() - - expectTypeOf(action.payload).toBeVoid() - }, - rejected(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toBeVoid() - - expectTypeOf(action.error).toEqualTypeOf() - }, - settled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toBeVoid() - - if (isRejected(action)) { - expectTypeOf(action.error).toEqualTypeOf() - } else { - expectTypeOf(action.payload).toBeVoid() - } - }, - }), - testInfer: create.asyncThunk( - function payloadCreator(arg: TestArg, api) { - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - }, - fulfilled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf() - }, - rejected(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - expectTypeOf(action.error).toEqualTypeOf() - }, - settled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - if (isRejected(action)) { - expectTypeOf(action.error).toEqualTypeOf() - } else { - expectTypeOf(action.payload).toEqualTypeOf() - } - }, - }, - ), - testExplicitType: create.asyncThunk< - TestReturned, - TestArg, - { - rejectValue: TestReject - } - >( - function payloadCreator(arg, api) { - // here would be a circular reference - expectTypeOf(api.getState()).toBeUnknown() - // here would be a circular reference - expectTypeOf(api.dispatch).toMatchTypeOf< - ThunkDispatch - >() - - // so you need to cast inside instead - const getState = api.getState as () => StoreState - const dispatch = api.dispatch as StoreDispatch - - expectTypeOf(arg).toEqualTypeOf() - - expectTypeOf(api.rejectWithValue).toMatchTypeOf< - (value: TestReject) => any - >() - - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - }, - fulfilled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf() - }, - rejected(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - expectTypeOf(action.error).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf< - TestReject | undefined - >() - }, - settled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - if (isRejected(action)) { - expectTypeOf(action.error).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf< - TestReject | undefined - >() - } else { - expectTypeOf(action.payload).toEqualTypeOf() - } - }, - }, - ), - testPreTyped: preTypedAsyncThunk( - function payloadCreator(arg: TestArg, api) { - expectTypeOf(api.rejectWithValue).toMatchTypeOf< - (value: TestReject) => any - >() - - return Promise.resolve({ payload: 'foo' }) - }, - { - pending(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - }, - fulfilled(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - - expectTypeOf(action.payload).toEqualTypeOf() - }, - rejected(state, action) { - expectTypeOf(state).toEqualTypeOf() - - expectTypeOf(action.meta.arg).toEqualTypeOf() - expectTypeOf(action.error).toEqualTypeOf() + expectTypeOf(action.payload).toBeString() - expectTypeOf(action.payload).toEqualTypeOf< - TestReject | undefined - >() - }, - settled(state, action) { - expectTypeOf(state).toEqualTypeOf() + expectTypeOf(action.meta).toEqualTypeOf<'meta'>() - expectTypeOf(action.meta.arg).toEqualTypeOf() + expectTypeOf(action.payload).toBeString() - if (isRejected(action)) { - expectTypeOf(action.error).toEqualTypeOf() + expectTypeOf(action.meta).toEqualTypeOf<'meta'>() - expectTypeOf(action.payload).toEqualTypeOf< - TestReject | undefined - >() - } else { - expectTypeOf(action.payload).toEqualTypeOf() - } - }, - }, - ), - } - }, + expectTypeOf(action.error).toEqualTypeOf<'error'>() + }, + ), + }), }) const store = configureStore({ reducer: { test: slice.reducer } }) @@ -887,34 +693,6 @@ describe('type tests', () => { 'meta' > >() - - expectTypeOf(slice.actions.testInferVoid).toEqualTypeOf< - AsyncThunk - >() - - expectTypeOf(slice.actions.testInferVoid).toBeCallableWith() - - expectTypeOf(slice.actions.testInfer).toEqualTypeOf< - AsyncThunk - >() - - expectTypeOf(slice.actions.testExplicitType).toEqualTypeOf< - AsyncThunk - >() - - type TestInferThunk = AsyncThunk - - expectTypeOf(slice.caseReducers.testInfer.pending).toEqualTypeOf< - CaseReducer> - >() - - expectTypeOf(slice.caseReducers.testInfer.fulfilled).toEqualTypeOf< - CaseReducer> - >() - - expectTypeOf(slice.caseReducers.testInfer.rejected).toEqualTypeOf< - CaseReducer> - >() }) test('wrapping createSlice should be possible, with callback', () => { @@ -925,7 +703,7 @@ describe('type tests', () => { const createGenericSlice = < T, - Reducers extends SliceCaseReducers>, + Reducers extends Record, >({ name = '', initialState, @@ -992,4 +770,141 @@ describe('type tests', () => { }) buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } }) }) + test('Default `createSlice` should not allow `create.asyncThunk()`, but it should allow `create.reducer()` and `create.preparedReducer()`', () => { + const sliceWithoutAsyncThunks = createSlice({ + name: 'counter', + initialState: { + value: 0, + status: 'idle', + }, + reducers: (create) => { + expectTypeOf(create).not.toHaveProperty('asyncThunk') + return { + increment: create.reducer((state) => { + state.value += 1 + }), + + incrementByAmount: create.preparedReducer( + (payload: number) => ({ payload }), + (state, action: PayloadAction) => { + state.value += action.payload + }, + ), + } + }, + }) + }) + test('creators can disable themselves if state is incompatible', () => { + const toastCreator: ReducerCreator = { + type: toasterCreatorType, + create: () => ({ + _reducerDefinitionType: toasterCreatorType, + }), + handle({ type }, _definition, context) { + const toastOpened = createAction<{ message: string; id: string }>( + type + '/opened', + ) + const toastClosed = createAction(type + '/closed') + function openToast( + ms: number, + message: string, + ): ThunkAction { + return (dispatch, getState) => { + const id = nanoid() + dispatch(toastOpened({ message, id })) + setTimeout(() => { + dispatch(toastClosed(id)) + }, ms) + } + } + Object.assign(openToast, { toastOpened, toastClosed }) + ;(context as any as ReducerHandlingContext) + .addCase(toastOpened, (state, { payload: { message, id } }) => { + state.toasts[id] = { message } + }) + .addCase(toastClosed, (state, action) => { + delete state.toasts[action.payload] + }) + .exposeAction(openToast) + }, + } + + const createAppSlice = buildCreateSlice({ + creators: { toaster: toastCreator }, + }) + + const toastSlice = createAppSlice({ + name: 'toasts', + initialState: { toasts: {} } as ToastState, + reducers: (create) => ({ + toast: create.toaster(), + }), + }) + + expectTypeOf(toastSlice.actions.toast).toEqualTypeOf< + AddToastThunk<'toasts', 'toast'> + >() + + expectTypeOf(toastSlice.actions.toast).toBeCallableWith(100, 'hello') + + expectTypeOf( + toastSlice.actions.toast.toastOpened.type, + ).toEqualTypeOf<'toasts/toast/opened'>() + + const incompatibleSlice = createAppSlice({ + name: 'incompatible', + initialState: {}, + reducers: (create) => { + expectTypeOf(create).not.toHaveProperty('toaster') + return {} + }, + }) + }) }) + +interface Toast { + message: string +} + +interface ToastState { + toasts: Record +} + +interface AddToastThunk { + ( + ms: number, + message: string, + ): ThunkAction + toastOpened: PayloadActionCreator< + { message: string; id: string }, + `${SliceActionType}/opened` + > + toastClosed: PayloadActionCreator< + string, + `${SliceActionType}/closed` + > +} + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [toasterCreatorType]: ReducerCreatorEntry< + State extends ToastState + ? () => ReducerDefinition + : never, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof toasterCreatorType + > + ? AddToastThunk + : never + } + } + > + } +} diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index 6bbc6d331b..f192352eef 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -1,20 +1,49 @@ -import type { PayloadAction, WithSlice } from '@reduxjs/toolkit' +import { vi } from 'vitest' +import type { Draft, Patch } from 'immer' +import { applyPatches, enablePatches, produceWithPatches } from 'immer' +import type { + Action, + CaseReducer, + CaseReducerDefinition, + CreatorCaseReducers, + PayloadAction, + PayloadActionCreator, + ReducerCreator, + ReducerCreatorEntry, + ReducerCreators, + ReducerDefinition, + ReducerHandlingContext, + SliceActionType, + ThunkAction, + WithSlice, +} from '@reduxjs/toolkit' import { - asyncThunkCreator, buildCreateSlice, combineSlices, configureStore, createAction, + createNextState, createSlice, + isAnyOf, + nanoid, + preparedReducerCreator, + reducerCreator, } from '@reduxjs/toolkit' import { createConsole, getLog, mockConsole, } from 'console-testing-library/pure' +import type { IfMaybeUndefined, NoInfer } from '../tsHelpers' +enablePatches() type CreateSlice = typeof createSlice +const loaderCreatorType = Symbol('loaderCreatorType') +const historyMethodsCreatorType = Symbol('historyMethodsCreatorType') +const undoableCreatorType = Symbol('undoableCreatorType') +const patchCreatorType = Symbol('patchCreatorType') + describe('createSlice', () => { let restore: () => void @@ -644,258 +673,593 @@ describe('createSlice', () => { expect(injected.selectors).not.toBe(injected2.selectors) }) }) - describe('reducers definition with asyncThunks', () => { - it('is disabled by default', () => { + test('reducer and preparedReducer creators can be invoked for object syntax', () => { + const counterSlice = createSlice({ + name: 'counter', + initialState: 0, + reducers: { + incrementBy: reducerCreator.create( + (state, action) => state + action.payload, + ), + decrementBy: preparedReducerCreator.create( + (amount: number) => ({ + payload: amount, + }), + (state, action) => state - action.payload, + ), + }, + }) + + const { incrementBy, decrementBy } = counterSlice.actions + expect(counterSlice.reducer(0, incrementBy(1))).toBe(1) + expect(counterSlice.reducer(0, decrementBy(3))).toBe(-3) + }) + describe('custom slice reducer creators', () => { + const loaderCreator: ReducerCreator = { + type: loaderCreatorType, + create(reducers) { + return { + _reducerDefinitionType: loaderCreatorType, + ...reducers, + } + }, + handle({ reducerName, type }, { started, ended }, context) { + const startedAction = createAction(type + '/started') + const endedAction = createAction(type + '/ended') + + function thunkCreator(): ThunkAction< + { loaderId: string; end: () => void }, + unknown, + unknown, + Action + > { + return (dispatch) => { + const loaderId = nanoid() + dispatch(startedAction(loaderId)) + return { + loaderId, + end: () => { + dispatch(endedAction(loaderId)) + }, + } + } + } + Object.assign(thunkCreator, { + started: startedAction, + ended: endedAction, + }) + + if (started) context.addCase(startedAction, started) + if (ended) context.addCase(endedAction, ended) + + context.exposeAction(thunkCreator) + context.exposeCaseReducer({ started, ended }) + }, + } + test('allows passing custom reducer creators, which can add actions and case reducers', () => { expect(() => createSlice({ - name: 'test', - initialState: [] as any[], - reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }), + name: 'loader', + initialState: {} as Partial>, + reducers: () => ({ + addLoader: loaderCreator.create({}), + }), }), ).toThrowErrorMatchingInlineSnapshot( - `[Error: Cannot use \`create.asyncThunk\` in the built-in \`createSlice\`. Use \`buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })\` to create a customised version of \`createSlice\`.]`, + `[Error: Unsupported reducer type: Symbol(loaderCreatorType)]`, ) - }) - const createAppSlice = buildCreateSlice({ - creators: { asyncThunk: asyncThunkCreator }, - }) - 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]) - } - function settled(state: any[], action: any) { - state.push(['settledReducer', action]) - } + const createAppSlice = buildCreateSlice({ + creators: { loader: loaderCreator }, + }) - test('successful thunk', async () => { - const slice = createAppSlice({ - name: 'test', - initialState: [] as any[], + const loaderSlice = createAppSlice({ + name: 'loader', + initialState: {} as Partial>, reducers: (create) => ({ - thunkReducers: create.asyncThunk( - function payloadCreator(arg: string, api) { - return Promise.resolve('resolved payload') + addLoader: create.loader({ + started: (state, { payload }) => { + state[payload] = true }, - { pending, fulfilled, rejected, settled }, - ), + ended: (state, { payload }) => { + delete state[payload] + }, + }), }), + selectors: { + selectLoader: (state, id: string) => state[id], + }, }) + const { addLoader } = loaderSlice.actions + const { selectLoader } = loaderSlice.selectors + + expect(addLoader).toEqual(expect.any(Function)) + expect(addLoader.started).toEqual(expect.any(Function)) + expect(addLoader.started.type).toBe('loader/addLoader/started') + + const isLoaderAction = isAnyOf(addLoader.started, addLoader.ended) + const store = configureStore({ - reducer: slice.reducer, + reducer: { + [loaderSlice.reducerPath]: loaderSlice.reducer, + actions: (state: PayloadAction[] = [], action) => + isLoaderAction(action) ? [...state, action] : state, + }, }) - 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', - }, - ], - [ - 'settledReducer', - { - type: 'test/thunkReducers/fulfilled', - payload: 'resolved payload', - }, - ], + + expect(loaderSlice.selectSlice(store.getState())).toEqual({}) + + const { loaderId, end } = store.dispatch(addLoader()) + expect(selectLoader(store.getState(), loaderId)).toBe(true) + + end() + expect(selectLoader(store.getState(), loaderId)).toBe(undefined) + + expect(store.getState().actions).toEqual([ + addLoader.started(loaderId), + addLoader.ended(loaderId), ]) }) - test('rejected thunk', async () => { - const slice = createAppSlice({ - name: 'test', - initialState: [] as any[], - reducers: (create) => ({ - thunkReducers: create.asyncThunk( - // payloadCreator isn't allowed to return never - function payloadCreator(arg: string, api): any { - throw new Error('') + describe('creators can return multiple definitions to be spread, or something else entirely', () => { + function getInitialHistoryState(initialState: T): HistoryState { + return { + past: [], + present: initialState, + future: [], + } + } + const historyMethodsCreator: ReducerCreator< + typeof historyMethodsCreatorType + > = { + type: historyMethodsCreatorType, + create() { + return { + undo: this.reducer((state: HistoryState) => { + const historyEntry = state.past.pop() + if (historyEntry) { + applyPatches(state, historyEntry.undo) + state.future.unshift(historyEntry) + } + }), + redo: this.reducer((state: HistoryState) => { + const historyEntry = state.future.shift() + if (historyEntry) { + applyPatches(state, historyEntry.redo) + state.past.push(historyEntry) + } + }), + reset: { + _reducerDefinitionType: historyMethodsCreatorType, + type: 'reset', }, - { pending, fulfilled, rejected, settled }, - ), - }), - }) + } + }, + handle(details, definition, context) { + if (definition.type !== 'reset') { + throw new Error('unrecognised definition') + } + reducerCreator.handle( + details, + reducerCreator.create(() => context.getInitialState()), + context, + ) + }, + } - 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, + const undoableCreator: ReducerCreator = { + type: undoableCreatorType, + create: Object.assign( + function makeUndoable( + reducer: CaseReducer, + ): CaseReducer, A> { + return (state, action) => { + const [nextState, redoPatch, undoPatch] = produceWithPatches( + state, + (draft) => { + const result = reducer(draft.present, action) + if (typeof result !== 'undefined') { + draft.present = result + } + }, + ) + let finalState = nextState + const undoable = action.meta?.undoable ?? true + if (undoable) { + finalState = createNextState(finalState, (draft) => { + draft.past.push({ + undo: undoPatch, + redo: redoPatch, + }) + draft.future = [] + }) + } + return finalState + } }, - ], - [ - 'settledReducer', { - type: 'test/thunkReducers/rejected', - payload: undefined, - }, - ], - ]) - }) - - test('with options', async () => { - const slice = createAppSlice({ - name: 'test', - initialState: [] as any[], - reducers: (create) => ({ - thunkReducers: create.asyncThunk( - function payloadCreator(arg: string, api) { - return 'should not call this' + withoutPayload() { + return (options?: UndoableOptions) => ({ + payload: undefined, + meta: options, + }) }, - { - options: { - condition() { - return false - }, - dispatchConditionRejection: true, - }, - pending, - fulfilled, - rejected, - settled, + withPayload

() { + return ( + ...[payload, options]: IfMaybeUndefined< + P, + [payload?: P, options?: UndoableOptions], + [payload: P, options?: UndoableOptions] + > + ) => ({ payload: payload as P, meta: options }) }, - ), - }), + }, + ), + } + + const createAppSlice = buildCreateSlice({ + creators: { + historyMethods: historyMethodsCreator, + undoable: undoableCreator, + }, }) + test('history slice', () => { + const historySlice = createAppSlice({ + name: 'history', + initialState: getInitialHistoryState({ value: 1 }), + reducers: (create) => ({ + ...create.historyMethods(), + increment: create.preparedReducer( + create.undoable.withoutPayload(), + create.undoable((state) => { + state.value++ + }), + ), + incrementBy: create.preparedReducer( + create.undoable.withPayload(), + create.undoable((state, action) => { + state.value += action.payload + }), + ), + }), + selectors: { + selectValue: (state) => state.present.value, + }, + }) + const { + actions: { increment, incrementBy, undo, redo, reset }, + selectors: { selectValue }, + } = historySlice - const store = configureStore({ - reducer: slice.reducer, + const store = configureStore({ + reducer: { [historySlice.reducerPath]: historySlice.reducer }, + }) + + expect(selectValue(store.getState())).toBe(1) + + store.dispatch(increment()) + expect(selectValue(store.getState())).toBe(2) + + store.dispatch(undo()) + expect(selectValue(store.getState())).toBe(1) + + store.dispatch(incrementBy(3)) + expect(selectValue(store.getState())).toBe(4) + + store.dispatch(undo()) + expect(selectValue(store.getState())).toBe(1) + + store.dispatch(redo()) + expect(selectValue(store.getState())).toBe(4) + + store.dispatch(reset()) + expect(selectValue(store.getState())).toBe(1) }) - await store.dispatch(slice.actions.thunkReducers('test')) - expect(store.getState()).toMatchObject([ - [ - 'rejectedReducer', - { - type: 'test/thunkReducers/rejected', - payload: undefined, - meta: { condition: true }, + }) + describe('context methods throw errors if used incorrectly', () => { + const makeSliceWithHandler = ( + handle: ReducerCreator['handle'], + ) => { + const loaderCreator: ReducerCreator = { + type: loaderCreatorType, + create(reducers) { + return { + _reducerDefinitionType: loaderCreatorType, + ...reducers, + } }, - ], - [ - 'settledReducer', - { - type: 'test/thunkReducers/rejected', - payload: undefined, - meta: { condition: true }, + handle, + } + const createAppSlice = buildCreateSlice({ + creators: { + loader: loaderCreator, }, - ], - ]) - }) - - test('has caseReducers for the asyncThunk', async () => { - const slice = createAppSlice({ - name: 'test', - initialState: [], - reducers: (create) => ({ - thunkReducers: create.asyncThunk( - function payloadCreator(arg, api) { - return Promise.resolve('resolved payload') - }, - { pending, fulfilled, settled }, - ), - }), + }) + return createAppSlice({ + name: 'loader', + initialState: {} as Partial>, + reducers: (create) => ({ + addLoader: create.loader({}), + }), + }) + } + test('context.addCase throws if called twice for same type', () => { + expect(() => + makeSliceWithHandler((_details, _def, context) => { + context.addCase('foo', () => {}).addCase('foo', () => {}) + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: \`context.addCase\` cannot be called with two reducers for the same action type: foo]`, + ) }) + test('context.addCase throws if empty action type', () => { + expect(() => + makeSliceWithHandler((_details, _def, context) => { + context.addCase('', () => {}) + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: \`context.addCase\` cannot be called with an empty action type]`, + ) + }) + test('context.exposeAction throws if called twice for same reducer name', () => { + expect(() => + makeSliceWithHandler((_details, _def, context) => { + context.exposeAction(() => {}).exposeAction(() => {}) + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: context.exposeAction cannot be called twice for the same reducer definition: addLoader]`, + ) + }) + test('context.exposeCaseReducer throws if called twice for same reducer name', () => { + expect(() => + makeSliceWithHandler((_details, _def, context) => { + context.exposeCaseReducer({}).exposeCaseReducer({}) + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: context.exposeCaseReducer cannot be called twice for the same reducer definition: addLoader]`, + ) + }) + test('context.selectSlice throws if unable to find slice state', () => { + const patchCreator: ReducerCreator = { + type: patchCreatorType, + create: { _reducerDefinitionType: patchCreatorType }, + handle({ type }, _def, context) { + const patchedAction = createAction(type) + function patchThunk( + recipe: (draft: Draft) => void, + ): ThunkAction, unknown, Action> { + return (dispatch, getState) => { + const [, patches] = produceWithPatches( + context.selectSlice(getState()), + recipe, + ) + dispatch(patchedAction(patches)) + } + } + Object.assign(patchThunk, { patched: patchedAction }) + + function applyPatchesReducer( + state: Objectish, + action: PayloadAction, + ) { + return applyPatches(state, action.payload) + } + + ;(context as ReducerHandlingContext) + .addCase(patchedAction, applyPatchesReducer) + .exposeAction(patchThunk) + .exposeCaseReducer(applyPatchesReducer) + }, + } - expect(slice.caseReducers.thunkReducers.pending).toBe(pending) - expect(slice.caseReducers.thunkReducers.fulfilled).toBe(fulfilled) - expect(slice.caseReducers.thunkReducers.settled).toBe(settled) - // 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() - }) + const createAppSlice = buildCreateSlice({ + creators: { patcher: patchCreator }, + }) - 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, + const personSlice = createAppSlice({ + name: 'person', + initialState: { name: 'Alice' }, + reducers: (create) => ({ + patchPerson: create.patcher, + }), + }) + + const { patchPerson } = personSlice.actions + + const correctStore = configureStore({ + reducer: combineSlices(personSlice), + }) + + expect(correctStore.getState().person.name).toBe('Alice') + + expect(() => + correctStore.dispatch( + patchPerson((person) => { + person.name = 'Bob' }), - (state, action) => { - state.push(action) - }, ), - }), - }) + ).not.toThrow() - expect( - slice.reducer( - [], - slice.actions.prepared('test', 1, { message: 'err' }), - ), - ).toMatchInlineSnapshot(` - [ - { - "error": { - "message": "err", - }, - "meta": 1, - "payload": "test", - "type": "test/prepared", + expect(correctStore.getState().person.name).toBe('Bob') + + const incorrectStore = configureStore({ + reducer: { + somewhere: personSlice.reducer, }, - ] - `) - }) + }) - 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( - `[Error: Please use the \`create.preparedReducer\` notation for prepared action creators with the \`create\` notation.]`, - ) + expect(() => + incorrectStore.dispatch( + // @ts-expect-error state mismatch + patchPerson((person) => { + person.name = 'Charlie' + }), + ), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find "person" slice in state. In order for slice creators to use \`context.selectSlice\`, the slice must be nested in the state under its reducerPath: "person"]`, + ) + }) }) }) }) + +interface LoaderReducerDefinition + extends ReducerDefinition { + started?: CaseReducer> + ended?: CaseReducer> +} + +interface LoaderThunk { + (): ThunkAction< + { loaderId: string; end: () => void }, + unknown, + unknown, + Action + > + started: PayloadActionCreator< + string, + `${SliceActionType}/started` + > + ended: PayloadActionCreator< + string, + `${SliceActionType}/ended` + > +} + +interface PatchesState { + undo: Patch[] + redo: Patch[] +} + +interface HistoryState { + past: PatchesState[] + present: T + future: PatchesState[] +} + +interface UndoableOptions { + undoable?: boolean +} + +// nicked from immer +type Objectish = AnyObject | AnyArray | AnyMap | AnySet +type AnyObject = { + [key: string]: any +} +type AnyArray = Array +type AnySet = Set +type AnyMap = Map + +type PatchThunk< + Name extends string, + ReducerName extends PropertyKey, + ReducerPath extends string, + State, +> = { + ( + recipe: (draft: Draft) => void, + ): ThunkAction, unknown, Action> + patched: PayloadActionCreator> +} + +declare module '@reduxjs/toolkit' { + export interface SliceReducerCreators< + State, + CaseReducers extends CreatorCaseReducers, + Name extends string, + ReducerPath extends string, + > { + [loaderCreatorType]: ReducerCreatorEntry< + ( + reducers: Pick, 'ended' | 'started'>, + ) => LoaderReducerDefinition, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof loaderCreatorType + > + ? LoaderThunk + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof loaderCreatorType + > + ? Required< + Pick, 'ended' | 'started'> + > + : never + } + } + > + [historyMethodsCreatorType]: ReducerCreatorEntry< + State extends HistoryState + ? (this: ReducerCreators) => { + undo: CaseReducerDefinition + redo: CaseReducerDefinition + reset: ReducerDefinition & { + type: 'reset' + } + } + : never, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof historyMethodsCreatorType + > & { type: 'reset' } + ? PayloadActionCreator> + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof historyMethodsCreatorType + > & { type: 'reset' } + ? CaseReducer + : never + } + } + > + [undoableCreatorType]: ReducerCreatorEntry< + State extends HistoryState + ? { + ( + this: ReducerCreators, + reducer: CaseReducer>, + ): CaseReducer + withoutPayload(): (options?: UndoableOptions) => { + payload: undefined + meta: UndoableOptions | undefined + } + withPayload

(): ( + ...args: IfMaybeUndefined< + P, + [payload?: P, options?: UndoableOptions], + [payload: P, options?: UndoableOptions] + > + ) => { payload: P; meta: UndoableOptions | undefined } + } + : never + > + [patchCreatorType]: ReducerCreatorEntry< + State extends Objectish + ? ReducerDefinition + : never, + { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof patchCreatorType + > + ? PatchThunk + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition< + typeof patchCreatorType + > + ? CaseReducer> + : never + } + } + > + } +} diff --git a/packages/toolkit/src/tsHelpers.ts b/packages/toolkit/src/tsHelpers.ts index ad5222b7b6..9711c68918 100644 --- a/packages/toolkit/src/tsHelpers.ts +++ b/packages/toolkit/src/tsHelpers.ts @@ -221,3 +221,26 @@ export function asSafePromise( ) { return promise.catch(fallback) as SafePromise } + +type NotUnknown = IsUnknown + +export type OverloadedReturnType any> = + Fn extends { + (...args: any[]): infer R1 + (...args: any[]): infer R2 + (...args: any[]): infer R3 + (...args: any[]): infer R4 + (...args: any[]): infer R5 + } + ? + | NotUnknown + | NotUnknown + | NotUnknown + | NotUnknown + | NotUnknown + : ReturnType + +export type Increment< + N extends number, + Acc extends 0[] = [], +> = Acc['length'] extends N ? [...Acc, 0]['length'] : Increment diff --git a/website/sidebars.json b/website/sidebars.json index ef79ee1a75..d382ab6977 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -34,7 +34,8 @@ "usage/usage-guide", "usage/usage-with-typescript", "usage/immer-reducers", - "usage/nextjs" + "usage/nextjs", + "usage/custom-slice-creators" ] }, {