Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment with allowing providing custom slice creators when calling createSlice #4348

Draft
wants to merge 10 commits into
base: entity-methods-creator
Choose a base branch
from
14 changes: 12 additions & 2 deletions docs/api/createSlice.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReducerFunction | ReducerAndPrepareObject>,
// An object of "case reducers", or a callback that returns an object. Key names will be used to generate actions.
reducers: Record<string, ReducerFunction | ReducerAndPrepareObject> | ((create: ReducerCreators<State>) => Record<string, ReducerDefinition>),
// A "builder callback" function used to add more reducers
extraReducers?: (builder: ActionReducerMapBuilder<State>) => 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<string, (sliceState: State, ...args: any[]) => any>,
// An object of custom slice creators, used by the reducer callback.
creators?: Record<string, ReducerCreator>
})
```

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions docs/usage/custom-slice-creators.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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` method, 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'
Expand Down
5 changes: 3 additions & 2 deletions errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
86 changes: 72 additions & 14 deletions packages/toolkit/src/createSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ interface InternalReducerHandlingContext<State> {

sliceCaseReducersByName: Record<string, any>
actionCreators: Record<string, any>

sliceCreators: Record<string, ReducerCreator<RegisteredReducerType>['create']>
sliceCreatorHandlers: Partial<
Record<
RegisteredReducerType,
ReducerCreator<RegisteredReducerType>['handle']
>
>
}

export interface ReducerHandlingContext<State> {
Expand Down Expand Up @@ -494,12 +502,13 @@ export interface CreateSliceOptions<
State,
Name,
ReducerPath,
CreatorMap
CreatorMap & SliceCreatorMap
> = SliceCaseReducers<State>,
Name extends string = string,
ReducerPath extends string = Name,
Selectors extends SliceSelectors<State> = SliceSelectors<State>,
CreatorMap extends Record<string, RegisteredReducerType> = {},
SliceCreatorMap extends Record<string, RegisteredReducerType> = {},
> {
/**
* The slice's name. Used to namespace the generated action types.
Expand Down Expand Up @@ -571,6 +580,10 @@ createSlice({
* A map of selectors that receive the slice's state and any additional arguments, and return a result.
*/
selectors?: Selectors

creators?: CreatorOption<SliceCreatorMap> & {
[K in keyof CreatorMap]?: never
}
}

export interface CaseReducerDefinition<
Expand Down Expand Up @@ -801,14 +814,18 @@ const isCreatorCallback = (
): reducers is CreatorCallback<any, any, any, any> =>
typeof reducers === 'function'

type CreatorOption<CreatorMap extends Record<string, RegisteredReducerType>> = {
[Name in keyof CreatorMap]: Name extends 'reducer' | 'preparedReducer'
? never
: ReducerCreator<CreatorMap[Name]>
} & {
asyncThunk?: ReducerCreator<ReducerType.asyncThunk>
}

interface BuildCreateSliceConfig<
CreatorMap extends Record<string, RegisteredReducerType>,
> {
creators?: {
[Name in keyof CreatorMap]: Name extends 'reducer' | 'preparedReducer'
? never
: ReducerCreator<CreatorMap[Name]>
} & { asyncThunk?: ReducerCreator<ReducerType.asyncThunk> }
creators?: CreatorOption<CreatorMap>
}

export function buildCreateSlice<
Expand Down Expand Up @@ -865,27 +882,42 @@ export function buildCreateSlice<
State,
CaseReducers extends
| SliceCaseReducers<State>
| CreatorCallback<State, Name, ReducerPath, CreatorMap>,
| CreatorCallback<State, Name, ReducerPath, CreatorMap & SliceCreatorMap>,
Name extends string,
Selectors extends SliceSelectors<State>,
ReducerPath extends string = Name,
SliceCreatorMap extends Record<string, RegisteredReducerType> = {},
>(
options: CreateSliceOptions<
State,
CaseReducers,
Name,
ReducerPath,
Selectors,
CreatorMap
CreatorMap,
SliceCreatorMap
>,
): Slice<
State,
GetCaseReducers<State, Name, ReducerPath, CreatorMap, CaseReducers>,
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<RegisteredReducerType>
>,
} = options
if (!name) {
throw new Error('`name` is a required option for createSlice')
}
Expand All @@ -908,6 +940,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) {
Expand Down Expand Up @@ -973,15 +1019,15 @@ 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') {
throw new Error(
'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)}`)
}
Expand Down Expand Up @@ -1081,7 +1127,13 @@ export function buildCreateSlice<
): Pick<
Slice<
State,
GetCaseReducers<State, Name, ReducerPath, CreatorMap, CaseReducers>,
GetCaseReducers<
State,
Name,
ReducerPath,
CreatorMap & SliceCreatorMap,
CaseReducers
>,
Name,
CurrentReducerPath,
Selectors
Expand Down Expand Up @@ -1137,7 +1189,13 @@ export function buildCreateSlice<

const slice: Slice<
State,
GetCaseReducers<State, Name, ReducerPath, CreatorMap, CaseReducers>,
GetCaseReducers<
State,
Name,
ReducerPath,
CreatorMap & SliceCreatorMap,
CaseReducers
>,
Name,
ReducerPath,
Selectors
Expand Down
31 changes: 31 additions & 0 deletions packages/toolkit/src/tests/createSlice.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions packages/toolkit/src/tests/createSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
mockConsole,
} from 'console-testing-library/pure'
import type { IfMaybeUndefined, NoInfer } from '../tsHelpers'

enablePatches()

type CreateSlice = typeof createSlice
Expand Down Expand Up @@ -1102,6 +1103,43 @@ describe('createSlice', () => {
)
})
})
test('creators can be provided per createSlice call', () => {
const loaderSlice = createSlice({
name: 'loader',
initialState: {} as Partial<Record<string, true>>,
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<Record<string, true>>,
// @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]`,
)
})
})
})

Expand Down