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
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<string>(),
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, along with any creators passed to buildCreateSlice
.
- TypeScript
- JavaScript
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<number>((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
import { buildCreateSlice, asyncThunkCreator, nanoid } from '@reduxjs/toolkit'
const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator },
})
const todosSlice = createAppSlice({
name: 'todos',
initialState: {
loading: false,
todos: [],
},
reducers: (create) => ({
deleteTodo: create.reducer((state, action) => {
state.todos.splice(action.payload, 1)
}),
addTodo: create.preparedReducer(
(text) => {
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, thunkApi) => {
const res = await fetch(`myApi/todos?id=${id}`)
return await res.json()
},
{
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.
create.reducer<Todo>((state, action) => {
state.todos.push(action.payload)
})
The creator definition for create.reducer
is exported from RTK as reducerCreator
, to allow reuse.
create.preparedReducer
A prepared reducer, to customize the action creator. Creates a prepared action creator with the same name as the reducer.
Parameters
- prepareAction The
prepare callback
. - reducer The slice case reducer to use.
The action passed to the case reducer will be inferred from the prepare callback's return.
create.preparedReducer(
(text: string) => {
const id = nanoid()
return { payload: { id, text } }
},
(state, action) => {
state.todos.push(action.payload)
},
)
The creator definition 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
.
The name the creator is available under is based on the key used when calling buildCreateSlice
. For example, to use create.asyncThunk
:
- TypeScript
- JavaScript
import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: {
asyncThunk: asyncThunkCreator,
},
})
interface Post {
id: string
text: string
}
export const postsSlice = createAppSlice({
name: 'posts',
initialState: [] as Post[],
reducers: (create) => ({
fetchPosts: create.asyncThunk(
async () => {
const res = await fetch('myApi/posts')
return (await res.json()) as Post[]
},
{
fulfilled(state, action) {
return action.payload
},
}
),
}),
})
import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: {
asyncThunk: asyncThunkCreator,
},
})
export const postsSlice = createAppSlice({
name: 'posts',
initialState: [],
reducers: (create) => ({
fetchPosts: create.asyncThunk(
async () => {
const res = await fetch('myApi/posts')
return await res.json()
},
{
fulfilled(state, action) {
return action.payload
},
}
),
}),
})
For clarity these docs will use recommended names.
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.
To avoid collision, names used by 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.
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.
- config The configuration object. (optional)
The configuration object can contain case reducers for each of the 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
.
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,
},
}
)
Typing for create.asyncThunk
works in the same way as 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.
create.asyncThunk<Todo, string, { rejectValue: { error: string } }>(
// may need to include an explicit return type
async (id: string, thunkApi): Promise<Todo> => {
// Cast types for `getState` and `dispatch` manually
const state = thunkApi.getState() as RootState
const dispatch = thunkApi.dispatch as AppDispatch
try {
const todo = await fetchTodo()
return todo
} catch (e) {
throw thunkApi.rejectWithValue({
error: 'Oh no!',
})
}
},
)
For common thunk API configuration options, a withTypes
helper is provided:
reducers: (create) => {
const createAThunk = create.asyncThunk.withTypes<{
rejectValue: { error: string }
}>()
return {
fetchTodo: createAThunk<Todo, string>(async (id, thunkApi) => {
throw thunkApi.rejectWithValue({
error: 'Oh no!',
})
}),
fetchTodos: createAThunk<Todo[], string>(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.
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, but it could return an object of multiple definitions to be spread into the final object, or something else entirely!
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
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.
import { buildCreateSlice } from '@reduxjs/toolkit'
const createAppSlice = buildCreateSlice({
creators: { batchable: batchableCreator },
})
const todoSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: (create) => ({
addTodo: create.batchable<Todo>((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.
const reducerCreatorType = Symbol('reducerCreatorType')
const reducerCreator: ReducerCreator<typeof reducerCreatorType> = {
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 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.
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), 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
- thereducerPath
passed tocreateSlice
(e.g.todos
) (defaults tosliceName
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
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.
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
for createReducer
and extraReducers
. Adds a case reducer which will be called when a given matcher returns true.
const matcher = isAnyOf(action, action2)
context.addMatcher(matcher, reducer)
Returns the context object to allow chaining.
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]
.
const action = createAction(type)
context.exposeAction(action)
Returns the context object to allow chaining.
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
.
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]
.
context.exposeCaseReducer(reducer)
Returns the context object to allow chaining.
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
.
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 section, which exposes case reducers for each of the lifecycle actions.
const asyncThunkCreator: ReducerCreator<typeof asyncThunkCreatorType> = {
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.
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.
const aThunk =
(): ThunkAction<void, Record<string, unknown>, 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.
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.
const reducerCreatorType = Symbol('reducerCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[reducerCreatorType]: ReducerCreatorEntry<
() => ReducerDefinition<typeof reducerCreatorType>
>
}
}
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
- Thename
used by the slice.ReducerPath
- ThereducerPath
used by the slice.
The ReducerCreatorEntry<Create, Exposes>
utility has two type parameters:
Create
The type of the create
value of the creator definition (typically a function signature).
CaseReducers
Your Create
type should not depend on the CaseReducers
type parameter, as these will not yet exist when the creator is being called.
this
to access other creatorsAssuming 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).
const batchedCreatorType = Symbol('batchedCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[batchedCreatorType]: ReducerCreatorEntry<
<Payload>(
this: ReducerCreators<State, {}>,
reducer: CaseReducer<State, PayloadAction<Payload>>,
) => PreparedCaseReducerDefinition<
State,
(payload: Payload) => { payload: Payload; meta: unknown }
>
>
}
}
const batchedCreator: ReducerCreator<typeof batchedCreatorType> = {
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<State, { asyncThunk: typeof asyncThunkCreator.type }>
would allow you to call this.asyncThunk
.
Alternatively, you can import the other creator's definition and use it directly.
import {
preparedReducerCreator,
PreparedCaseReducerDefinition,
} from '@reduxjs/toolkit'
const batchedCreatorType = Symbol('batchedCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[batchedCreatorType]: ReducerCreatorEntry<
<Payload>(
reducer: CaseReducer<State, PayloadAction<Payload>>,
) => PreparedCaseReducerDefinition<
State,
(payload: Payload) => { payload: Payload; meta: unknown }
>
>
}
}
const batchedCreator: ReducerCreator<typeof batchedCreatorType> = {
type: batchedCreatorType,
create(reducer) {
return preparedReducerCreator.create(prepareAutoBatched(), reducer)
},
}
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:
const loaderCreatorType = Symbol('loaderCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[loaderCreatorType]: ReducerCreatorEntry<
State extends { loading: boolean }
? () => {
start: CaseReducerDefinition<State, PayloadAction>
end: CaseReducerDefinition<State, PayloadAction>
}
: 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.
const loaderCreatorType = Symbol('loaderCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
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 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.
{
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition<typeof creatorType> ? 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:
const asyncThunkCreatorType = Symbol('asyncThunkCreatorType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[asyncThunkCreatorType]: ReducerCreatorEntry<
<ThunkArg, Returned>(
payloadCreator: AsyncThunkPayloadCreator<ThunkArg, Returned>,
) => AsyncThunkReducerDefinition<State, ThunkArg, Returned>,
{
actions: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkReducerDefinition<
State,
infer ThunkArg,
infer Returned
>
? AsyncThunk<ThunkArg, Returned>
: never
}
}
>
}
}
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:
const preparedReducerType = Symbol('preparedReducerType')
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[preparedReducerType]: ReducerCreatorEntry<
<Prepare extends PrepareAction<any>>(
prepare: Prepare,
caseReducer: CaseReducer<State, ActionForPrepare<Prepare>>,
) => PreparedCaseReducerDefinition<State, Prepare>,
{
actions: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends PreparedCaseReducerDefinition<
any,
any
>
? ActionCreatorForCaseReducerWithPrepare<
CaseReducers[ReducerName],
SliceActionType<Name, ReducerName>
>
: never
}
caseReducers: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends PreparedCaseReducerDefinition<
any,
any
>
? CaseReducers[ReducerName]['reducer']
: never
}
}
>
}
}
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.
buildCreateSlice
usageFor 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.
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.
// create the unique type
const toastCreatorType = Symbol('toastCreatorType')
interface Toast {
message: string
}
interface ToastReducerConfig<State> {
shown?: CaseReducer<State, PayloadAction<Toast, string, { id: string }>>
hidden?: CaseReducer<State, PayloadAction<undefined, string, { id: string }>>
}
interface ToastReducerDefinition<State>
extends ReducerDefinition<typeof toastCreatorType>,
ToastReducerConfig<State> {}
interface ToastThunkCreator<
SliceName extends string,
ReducerName extends string,
> {
(
toast: Toast,
timeout?: number,
): ThunkAction<{ hide(): void }, unknown, unknown, UnknownAction>
shown: PayloadActionCreator<
Toast,
`${SliceActionType<SliceName, ReducerName>}/shown`,
(toast: Toast, id: string) => { payload: Toast; meta: { id: string } }
>
hidden: PayloadActionCreator<
void,
`${SliceActionType<SliceName, ReducerName>}/hidden`,
(id: string) => { payload: undefined; meta: { id: string } }
>
}
// register the creator types
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[toastCreatorType]: ReducerCreatorEntry<
(config: ToastReducerConfig<State>) => ToastReducerDefinition<State>,
{
actions: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ToastReducerDefinition<State>
? ToastThunkCreator<Name, ReducerName>
: never
}
caseReducers: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ToastReducerDefinition<State>
? Required<ToastReducerConfig<State>>
: never
}
}
>
}
}
// define the creator
const toastCreator: ReducerCreator<typeof toastCreatorType> = {
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<string, Toast>,
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
approach, as you could call multiple creators as needed.
One example could be returning some pagination related reducers.
const paginationCreatorType = Symbol('paginationCreatorType')
interface PaginationState {
page: number
}
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[paginationCreatorType]: ReducerCreatorEntry<
// make sure the creator is only called when state is compatible
State extends PaginationState
? (this: ReducerCreators<State>) => {
prevPage: CaseReducerDefinition<State, PayloadAction>
nextPage: CaseReducerDefinition<State, PayloadAction>
goToPage: CaseReducerDefinition<State, PayloadAction<number>>
}
: never
>
}
}
const paginationCreator: ReducerCreator<typeof paginationCreatorType> = {
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<number>((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:
const historyCreatorType = Symbol('historyCreatorType')
interface PatchesState {
undo: Patch[]
redo: Patch[]
}
interface HistoryState<T> {
past: PatchesState[]
present: T
future: PatchesState[]
}
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[historyCreatorType]: ReducerCreatorEntry<
// make sure the creator is only called when state is compatible
State extends HistoryState<any>
? (this: ReducerCreators<State>) => {
undo: CaseReducerDefinition<State, PayloadAction>
redo: CaseReducerDefinition<State, PayloadAction>
reset: ReducerDefinition<typeof historyCreatorType> & {
type: 'reset'
}
}
: never,
{
actions: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition<
typeof historyCreatorType
>
? CaseReducers[ReducerName] extends { type: 'reset' }
? PayloadActionCreator<void, SliceActionType<Name, ReducerName>>
: never
: never
}
caseReducers: {
[ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends ReducerDefinition<
typeof historyCreatorType
>
? CaseReducers[ReducerName] extends { type: 'reset' }
? CaseReducer<State, PayloadAction>
: never
: never
}
}
>
}
}
const historyCreator: ReducerCreator<typeof historyCreatorType> = {
type: historyCreatorType,
create() {
return {
undo: this.reducer((state: HistoryState<unknown>) => {
const historyEntry = state.past.pop()
if (historyEntry) {
applyPatches(state, historyEntry.undo)
state.future.unshift(historyEntry)
}
}),
redo: this.reducer((state: HistoryState<unknown>) => {
const historyEntry = state.future.shift()
if (historyEntry) {
applyPatches(state, historyEntry.redo)
state.past.push(historyEntry)
}
}),
// here we're creating a reducer definition that our `handle` method will be called with
reset: {
_reducerDefinitionType: historyCreatorType,
type: 'reset',
},
}
},
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<T>(initialState: T): HistoryState<T> {
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:
const undoableCreatorType = Symbol('undoableCreatorType')
interface UndoableMeta {
undoable?: boolean
}
declare module '@reduxjs/toolkit' {
export interface SliceReducerCreators<
State,
CaseReducers extends CreatorCaseReducers<State>,
Name extends string,
ReducerPath extends string,
> {
[undoableCreatorType]: ReducerCreatorEntry<
State extends HistoryState<infer Data>
? {
<A extends Action & { meta?: UndoableMeta }>(
reducer: CaseReducer<Data, A>,
): CaseReducer<State, A>
withoutPayload(options?: UndoableMeta): {
payload: undefined
meta: UndoableMeta | undefined
}
withPayload<Payload>(
payload: Payload,
options?: UndoableMeta,
): { payload: Payload; meta: UndoableMeta | undefined }
}
: never
>
}
}
const undoableCreator: ReducerCreator<typeof undoableCreatorType> = {
type: undoableCreatorType,
create: Object.assign(
function makeUndoable<A extends Action & { meta?: UndoableOptions }>(
reducer: CaseReducer<any, A>,
): CaseReducer<HistoryState<any>, 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<P>() {
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<string>(),
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
history-adapter
This example is a somewhat simplified version of the history-adapter
package, which provides a createHistoryAdapter
utility that can be used to add undo/redo functionality to a slice.
import {
createHistoryAdapter,
historyMethodsCreator,
undoableCreatorsCreator,
} from 'history-adapter/redux'
const createAppSlice = buildCreateSlice({
creators: {
historyMethods: historyMethodsCreator,
undoableCreators: undoableCreatorsCreator,
},
})
const postHistoryAdapter = createHistoryAdapter<Post>({ 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<string>(),
(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