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

Allow providing custom slice creators when calling createSlice #4348

Open
wants to merge 21 commits into
base: entity-methods-creator
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a4ba0ce
Experiment with allowing providing custom slice creators when calling…
EskiMojo14 Apr 15, 2024
e9e7818
rm unused import
EskiMojo14 Apr 15, 2024
108f6b3
add type test for overlapping names
EskiMojo14 Apr 15, 2024
42363fa
add overlap test
EskiMojo14 May 4, 2024
15f01b9
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 May 22, 2024
d976ea5
move async thunk creator module augmentation
EskiMojo14 May 22, 2024
27e0ef8
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 May 22, 2024
71e3d04
create util to cut down on repetitive code
EskiMojo14 May 22, 2024
bae8ce9
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 May 22, 2024
fea70e4
update docs
EskiMojo14 May 29, 2024
0ccf470
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Sep 1, 2024
ed623ac
fix creator issue
EskiMojo14 Sep 3, 2024
4decf83
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Sep 3, 2024
083d6e4
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Sep 3, 2024
5654eb2
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Oct 25, 2024
4de4ab7
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Oct 25, 2024
e4dbc9a
Retry CSB build
EskiMojo14 Oct 25, 2024
5d1969d
fix Id usage
EskiMojo14 Oct 25, 2024
d105924
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Oct 25, 2024
25bc1a7
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Oct 25, 2024
0d28d41
Merge branch 'entity-methods-creator' into per-slice-creator
EskiMojo14 Nov 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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` 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'
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 @@ -191,6 +191,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 @@ -506,12 +514,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 @@ -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<SliceCreatorMap> & {
[K in keyof CreatorMap]?: never
}
}

export type CaseReducerDefinition<
Expand Down Expand Up @@ -812,14 +825,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 @@ -876,27 +893,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 @@ -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) {
Expand Down Expand Up @@ -984,15 +1030,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 @@ -1092,7 +1138,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 @@ -1148,7 +1200,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 @@ -1100,6 +1101,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