diff --git a/docs/api/createSlice.mdx b/docs/api/createSlice.mdx index 61a897eda5..0e76a10cf8 100644 --- a/docs/api/createSlice.mdx +++ b/docs/api/createSlice.mdx @@ -57,14 +57,16 @@ function createSlice({ name: string, // The initial state for the reducer initialState: State, - // An object of "case reducers". Key names will be used to generate actions. - reducers: Record, + // An object of "case reducers", or a callback that returns an object. Key names will be used to generate actions. + reducers: Record | ((create: ReducerCreators) => Record), // A "builder callback" function used to add more reducers extraReducers?: (builder: ActionReducerMapBuilder) => void, // A preference for the slice reducer's location, used by `combineSlices` and `slice.selectors`. Defaults to `name`. reducerPath?: string, // An object of selectors, which receive the slice's state as their first parameter. selectors?: Record any>, + // An object of custom slice creators, used by the reducer callback. + creators?: Record }) ``` @@ -456,6 +458,14 @@ const counterSlice = createSlice({ ::: +### `creators` + +While typically [custom creators](/usage/custom-slice-creators) will be provided on a per-app basis (see [`buildCreateSlice`](#buildcreateslice)), this field allows for custom slice creators to be passed in per slice. + +This is particularly useful when using a custom creator that is specific to a single slice. + +An error will be thrown if there is a naming conflict between an app-wide custom creator and a slice-specific custom creator. + ## Return Value `createSlice` will return an object that looks like: diff --git a/docs/usage/custom-slice-creators.mdx b/docs/usage/custom-slice-creators.mdx index bf71565083..900c8545d2 100644 --- a/docs/usage/custom-slice-creators.mdx +++ b/docs/usage/custom-slice-creators.mdx @@ -48,6 +48,12 @@ const { undo, redo, reset, updateTitle, togglePinned } = 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). +:::note + +Creators can also be [passed per slice](/api/createSlice#creators), but most creators will be useful in more than one slice - so it's recommended to pass them to `buildCreateSlice` instead. + +::: + ```ts title="Creator callback for reducers" import { buildCreateSlice, asyncThunkCreator, nanoid } from '@reduxjs/toolkit' @@ -166,7 +172,7 @@ The [creator definition](#creator-definitions) for `create.preparedReducer` is e 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`: +The name the creator is available under is based on the key used when calling `buildCreateSlice` (or [`createSlice`](/api/createSlice#creators)). For example, to use `create.asyncThunk`: ```ts import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit' @@ -464,7 +470,7 @@ Typically a creator will return a [single reducer definition](#single-definition 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. +It's passed to [`buildCreateSlice`](../api/createSlice#buildcreateslice) (or [`createSlice`](/api/createSlice#creators)) 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' diff --git a/errors.json b/errors.json index 4bdd52e2b6..c3d1e22da0 100644 --- a/errors.json +++ b/errors.json @@ -46,5 +46,6 @@ "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: \"\"" -} + "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: \"\"", + "48": "A creator with the name has already been provided to buildCreateSlice" +} \ No newline at end of file diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index 3725913375..89d38d1ef1 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -191,6 +191,14 @@ interface InternalReducerHandlingContext { sliceCaseReducersByName: Record actionCreators: Record + + sliceCreators: Record['create']> + sliceCreatorHandlers: Partial< + Record< + RegisteredReducerType, + ReducerCreator['handle'] + > + > } export interface ReducerHandlingContext { @@ -506,12 +514,13 @@ export interface CreateSliceOptions< State, Name, ReducerPath, - CreatorMap + CreatorMap & SliceCreatorMap > = SliceCaseReducers, Name extends string = string, ReducerPath extends string = Name, Selectors extends SliceSelectors = SliceSelectors, CreatorMap extends Record = {}, + SliceCreatorMap extends Record = {}, > { /** * The slice's name. Used to namespace the generated action types. @@ -583,6 +592,10 @@ createSlice({ * A map of selectors that receive the slice's state and any additional arguments, and return a result. */ selectors?: Selectors + + creators?: CreatorOption & { + [K in keyof CreatorMap]?: never + } } export type CaseReducerDefinition< @@ -812,14 +825,18 @@ const isCreatorCallback = ( ): reducers is CreatorCallback => typeof reducers === 'function' +type CreatorOption> = { + [Name in keyof CreatorMap]: Name extends 'reducer' | 'preparedReducer' + ? never + : ReducerCreator +} & { + asyncThunk?: ReducerCreator +} + interface BuildCreateSliceConfig< CreatorMap extends Record, > { - creators?: { - [Name in keyof CreatorMap]: Name extends 'reducer' | 'preparedReducer' - ? never - : ReducerCreator - } & { asyncThunk?: ReducerCreator } + creators?: CreatorOption } export function buildCreateSlice< @@ -876,10 +893,11 @@ export function buildCreateSlice< State, CaseReducers extends | SliceCaseReducers - | CreatorCallback, + | CreatorCallback, Name extends string, Selectors extends SliceSelectors, ReducerPath extends string = Name, + SliceCreatorMap extends Record = {}, >( options: CreateSliceOptions< State, @@ -887,16 +905,30 @@ export function buildCreateSlice< Name, ReducerPath, Selectors, - CreatorMap + CreatorMap, + SliceCreatorMap >, ): Slice< State, - GetCaseReducers, + GetCaseReducers< + State, + Name, + ReducerPath, + CreatorMap & SliceCreatorMap, + CaseReducers + >, Name, ReducerPath, Selectors > { - const { name, reducerPath = name as unknown as ReducerPath } = options + const { + name, + reducerPath = name as unknown as ReducerPath, + creators: sliceCreators = {} as Record< + string, + ReducerCreator + >, + } = options if (!name) { throw new Error('`name` is a required option for createSlice') } @@ -919,6 +951,20 @@ export function buildCreateSlice< sliceCaseReducersByType: {}, actionCreators: {}, sliceMatchers: [], + sliceCreators: { ...creators }, + sliceCreatorHandlers: { ...handlers }, + } + + for (const [name, creator] of Object.entries(sliceCreators)) { + if (name in creators) { + throw new Error( + `A creator with the name ${name} has already been provided to buildCreateSlice`, + ) + } + internalContext.sliceCreators[name] = creator.create + if ('handle' in creator) { + internalContext.sliceCreatorHandlers[creator.type] = creator.handle + } } function getContext({ reducerName }: ReducerDetails) { @@ -984,7 +1030,7 @@ export function buildCreateSlice< } if (isCreatorCallback(options.reducers)) { - const reducers = options.reducers(creators as any) + const reducers = options.reducers(internalContext.sliceCreators as any) for (const [reducerName, reducerDefinition] of Object.entries(reducers)) { const { _reducerDefinitionType: type } = reducerDefinition if (typeof type === 'undefined') { @@ -992,7 +1038,7 @@ export function buildCreateSlice< 'Please use reducer creators passed to callback. Each reducer definition must have a `_reducerDefinitionType` property indicating which handler to use.', ) } - const handle = handlers[type] + const handle = internalContext.sliceCreatorHandlers[type] if (!handle) { throw new Error(`Unsupported reducer type: ${String(type)}`) } @@ -1092,7 +1138,13 @@ export function buildCreateSlice< ): Pick< Slice< State, - GetCaseReducers, + GetCaseReducers< + State, + Name, + ReducerPath, + CreatorMap & SliceCreatorMap, + CaseReducers + >, Name, CurrentReducerPath, Selectors @@ -1148,7 +1200,13 @@ export function buildCreateSlice< const slice: Slice< State, - GetCaseReducers, + GetCaseReducers< + State, + Name, + ReducerPath, + CreatorMap & SliceCreatorMap, + CaseReducers + >, Name, ReducerPath, Selectors diff --git a/packages/toolkit/src/tests/createSlice.test-d.ts b/packages/toolkit/src/tests/createSlice.test-d.ts index fd65e99f4e..2cd43b85f1 100644 --- a/packages/toolkit/src/tests/createSlice.test-d.ts +++ b/packages/toolkit/src/tests/createSlice.test-d.ts @@ -860,6 +860,37 @@ describe('type tests', () => { }, }) }) + test('creators can be provided during createSlice call but cannot overlap', () => { + const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, + }) + + createAppSlice({ + name: 'counter', + initialState: 0, + creators: { + something: asyncThunkCreator, + }, + reducers: (create) => { + expectTypeOf(create).toHaveProperty('asyncThunk') + expectTypeOf(create).toHaveProperty('something') + return {} + }, + }) + + createAppSlice({ + name: 'counter', + initialState: 0, + // @ts-expect-error + creators: { + asyncThunk: asyncThunkCreator, + }, + reducers: (create) => { + expectTypeOf(create).toHaveProperty('asyncThunk') + return {} + }, + }) + }) }) interface Toast { diff --git a/packages/toolkit/src/tests/createSlice.test.ts b/packages/toolkit/src/tests/createSlice.test.ts index f192352eef..169cd6a243 100644 --- a/packages/toolkit/src/tests/createSlice.test.ts +++ b/packages/toolkit/src/tests/createSlice.test.ts @@ -35,6 +35,7 @@ import { mockConsole, } from 'console-testing-library/pure' import type { IfMaybeUndefined, NoInfer } from '../tsHelpers' + enablePatches() type CreateSlice = typeof createSlice @@ -1100,6 +1101,43 @@ describe('createSlice', () => { ) }) }) + test('creators can be provided per createSlice call', () => { + const loaderSlice = createSlice({ + name: 'loader', + initialState: {} as Partial>, + creators: { loader: loaderCreator }, + reducers: (create) => ({ + addLoader: create.loader({}), + }), + }) + expect(loaderSlice.actions.addLoader).toEqual(expect.any(Function)) + expect(loaderSlice.actions.addLoader.started).toEqual( + expect.any(Function), + ) + expect(loaderSlice.actions.addLoader.started.type).toBe( + 'loader/addLoader/started', + ) + }) + test('error is thrown if there is name overlap between creators', () => { + const createAppSlice = buildCreateSlice({ + creators: { + loader: loaderCreator, + }, + }) + expect(() => + createAppSlice({ + name: 'loader', + initialState: {} as Partial>, + // @ts-expect-error name overlap + creators: { loader: loaderCreator }, + reducers: (create) => ({ + addLoader: create.loader({}), + }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: A creator with the name loader has already been provided to buildCreateSlice]`, + ) + }) }) })