Redux Toolkit 07 ⚛️ Action Creators — Generating custom actions with createAction
An action creator is a function that returns an action object. In classic Redux, every action required a type constant, an action creator, and a reducer case, and the three had to be kept in sync manually. Redux Toolkit’s createAction generates the action creator and the type string from a single call, and the action creator carries a .type property and a .match method that the reducer can use to identify the action without a separate constant file . The previous chapter covered Immer, which simplifies the reducer body; this chapter covers createAction, which simplifies the action definition and the standalone reducer that handles it.
The createAction function is not the primary way to define actions in a slice. The createSlice reducers object generates the action creators automatically, and that is the recommended approach for most code. But createAction is necessary when the action is defined outside a slice—in a standalone reducer, in a shared action module, or in an extraReducers handler for an action that belongs to another slice. The createAsyncThunk function is also built on createAction; it generates the pending, fulfilled, and rejected action creators using the same mechanism .
Key point: createAction(type) returns an action creator with three properties: the function itself, .type (the type string), and .match(action) (a type guard that returns true if the action has the matching type). The action creator accepts a single payload argument by default; a prepare callback can transform the arguments before they become the payload. The .match method is the key feature for standalone reducers, because it provides type narrowing without a separate constant.
Why createAction exists alongside createSlice
The standalone-reducer problem.
A slice’s reducers object generates action creators, but a reducer that is not part of a slice—a root reducer, a reducer for a feature that is split across multiple files, or a reducer that handles actions from several slices—needs action creators from elsewhere. createAction provides them. The returned action creator can be imported by any component that dispatches, and the returned .type and .match are used by the reducer that handles it .
The shared-action problem.
Some actions are dispatched by multiple slices. A logout action, for example, might be handled by the user slice, the cart slice, and the settings slice. Defining the action inside one slice’s reducers object makes it a dependency of the other slices. createAction defines the action in a shared module, and each slice handles it in its extraReducers using the .match method or the builder’s addCase with the action creator as the argument.
The prepare-callback problem.
The prepare callback in createAction is the same mechanism that createSlice uses for its reducers. It allows the action creator to accept multiple arguments and transform them into the payload. A createAction with a prepare callback returns an action creator whose signature matches the prepare function, not the payload type . This is useful for actions that need to generate an ID or a timestamp at dispatch time.
a. The basic action creator and its properties
The createAction call accepts a type string and an optional prepare callback. It returns a function that, when called, returns an action object with the type and payload fields.
import { createAction } from '@reduxjs/toolkit'
// Basic action creator: takes a payload
export const addTodo = createAction<string>('todos/addTodo')
// The action creator returns { type: 'todos/addTodo', payload: '...' }
const action = addTodo('Buy milk')
// action = { type: 'todos/addTodo', payload: 'Buy milk' }
// The .type property
console.log(addTodo.type) // 'todos/addTodo'
// The .match method
console.log(addTodo.match(action)) // true
console.log(addTodo.match({ type: 'other' })) // false
The createAction<string> type parameter declares the payload type. The action creator addTodo accepts a string and returns an action with that string as the payload. The .type property is the type string, and .match is a type guard that narrows the action to the payload type when it returns true .
The .match method is what makes createAction useful for standalone reducers. A reducer can use it to check the action type without a switch statement or a separate constant.
import { createReducer } from '@reduxjs/toolkit'
const todosReducer = createReducer<string[]>([], (builder) => {
builder
.addCase(addTodo, (state, action) => {
// action.payload is typed as string
state.push(action.payload)
})
})
The builder’s addCase accepts the action creator directly, and the action’s payload type is inferred from the creator’s type parameter. The .match method is used internally by the builder; the developer does not need to call it manually .
b. The prepare callback for transformed payloads
The prepare callback transforms the arguments passed to the action creator into the action’s payload. It is useful when the payload requires a generated ID, a timestamp, or a combination of several arguments.
import { createAction, nanoid } from '@reduxjs/toolkit'
interface Todo {
id: string
text: string
createdAt: number
}
// The prepare callback transforms (text) into the payload
export const createTodo = createAction('todos/create', (text: string) => {
return {
payload: {
id: nanoid(),
text,
createdAt: Date.now(),
} as Todo,
}
})
// The action creator accepts a string, not the Todo object
const action = createTodo('Buy milk')
// action.payload = { id: '...', text: 'Buy milk', createdAt: 1234567890 }
The action creator’s signature is determined by the prepare callback, not by the payload type. createTodo accepts a string, generates the ID and timestamp, and returns the full Todo object as the payload . The reducer that handles the action receives the Todo object, not the string.
The prepare callback can also add a meta field or an error field, which are the other two fields of a Redux action. The meta field carries metadata that is not part of the payload, and the error field marks the action as an error .
export const createTodoWithSource = createAction(
'todos/create',
(text: string, source: 'web' | 'mobile') => {
return {
payload: { id: nanoid(), text, createdAt: Date.now() },
meta: { source }, // Metadata not part of the payload
}
}
)
The meta field is available on the action object and can be read by the reducer. It is not part of the payload, so it does not affect the state’s serializability in the same way .
c. Using createAction in extraReducers
The createAction-generated action can be handled by any slice’s extraReducers. The builder’s addCase accepts the action creator, and the payload type is inferred.
import { createSlice, createAction } from '@reduxjs/toolkit'
// Shared action defined outside any slice
export const resetAll = createAction('app/resetAll')
const userSlice = createSlice({
name: 'user',
initialState: { name: '', loggedIn: false },
reducers: {
login: (state, action) => {
state.name = action.payload
state.loggedIn = true
},
},
extraReducers: (builder) => {
builder.addCase(resetAll, (state) => {
state.name = ''
state.loggedIn = false
})
},
})
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] as string[] },
reducers: {},
extraReducers: (builder) => {
builder.addCase(resetAll, (state) => {
state.items = []
})
},
})
The resetAll action is defined once, outside any slice. Both the user slice and the cart slice handle it in their extraReducers. The action creator is imported by the components that dispatch it, and the .type string is generated automatically . This is the pattern for actions that affect multiple features: define the action once, handle it in each slice that cares about it.
The builder’s addCase is preferred over the .match method in createSlice because it handles the Immer draft and the TypeScript inference automatically. The .match method is used in a switch statement or an if chain when the reducer is not built with the builder.
Complete Example Session
// ============================================
// PART 1: SHARED ACTIONS MODULE
// ============================================
import { createAction, nanoid } from '@reduxjs/toolkit'
// Simple action with a payload
export const increment = createAction<number>('counter/increment')
// Action with a generated payload
export const addNotification = createAction(
'notifications/add',
(message: string, level: 'info' | 'error') => ({
payload: { id: nanoid(), message, level, createdAt: Date.now() },
})
)
// Action with no payload
export const resetAll = createAction('app/resetAll')
// ============================================
// PART 2: THE COUNTER SLICE
// ============================================
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(increment, (state, action) => {
state.value += action.payload
})
.addCase(resetAll, (state) => {
state.value = 0
})
},
})
// ============================================
// PART 3: THE NOTIFICATIONS SLICE
// ============================================
const notificationsSlice = createSlice({
name: 'notifications',
initialState: { items: [] as { id: string; message: string; level: string }[] },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(addNotification, (state, action) => {
state.items.push(action.payload)
})
.addCase(resetAll, (state) => {
state.items = []
})
},
})
// ============================================
// PART 4: THE ROOT REDUCER
// ============================================
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {
counter: counterSlice.reducer,
notifications: notificationsSlice.reducer,
},
})
// ============================================
// PART 5: DISPATCHING THE ACTIONS
// ============================================
import { useAppDispatch } from './hooks'
function Controls() {
const dispatch = useAppDispatch()
return (
<div>
<button onClick={() => dispatch(increment(1))}>+1</button>
<button onClick={() => dispatch(increment(5))}>+5</button>
<button onClick={() => dispatch(addNotification('Saved', 'info'))}>Notify</button>
<button onClick={() => dispatch(resetAll())}>Reset all</button>
</div>
)
}
// ============================================
// PART 6: THE .match METHOD IN A STANDALONE REDUCER
// ============================================
import { createReducer } from '@reduxjs/toolkit'
const standaloneReducer = createReducer({ value: 0 }, (builder) => {
builder.addCase(increment, (state, action) => {
state.value += action.payload
})
})
// Alternatively, with .match in a switch:
function manualReducer(state = { value: 0 }, action: { type: string; payload?: number }) {
if (increment.match(action)) {
return { value: state.value + (action.payload ?? 0) }
}
return state
}
The six parts demonstrate the createAction surface: the shared actions module, the slices that handle them, the root reducer, the dispatch calls, and the .match method in a standalone reducer. The resetAll action is handled by both slices, which is the pattern for cross-slice actions.
Quick Reference
createAction Signatures
| Form | Signature | Payload |
|---|---|---|
createAction<T>(type) | (payload: T) => Action | T |
createAction(type, prepare) | (...args) => Action | From prepare |
createAction(type) | () => Action | undefined |
Action Creator Properties
| Property | Type | Purpose |
|---|---|---|
| The function | (payload) => Action | Creates the action |
.type | string | The type string |
.match(action) | (action) => boolean | Type guard |
prepare Return Shape
| Field | Purpose |
|---|---|
payload | The action payload |
meta | Metadata not in the payload |
error | Marks the action as an error |
Comparison with createSlice
| Feature | createAction | createSlice reducers |
|---|---|---|
| Action creator | Yes | Yes |
| Reducer | No | Yes |
| Immer draft | No | Yes |
| Use case | Shared actions, standalone reducers | Feature slices |
Best Practices
✅ Do This:
// Use createAction for shared actions
export const resetAll = createAction('app/resetAll') # ✅
// Use the .match method in standalone reducers
if (increment.match(action)) { ... } # ✅
// Use prepare for generated payloads
createAction('todos/add', (text: string) => ({ payload: { id: nanoid(), text } })) # ✅
// Handle shared actions in extraReducers
builder.addCase(resetAll, (state) => { ... }) # ✅
// Use the type parameter for payload typing
createAction<number>('counter/increment') # ✅
❌ Don’t Do This:
// Define action type constants manually
const INCREMENT = 'counter/increment' # ❌
// Call .match on a plain object
increment.match({ type: 'counter/increment' }) // Works but unnecessary # ❌
// Use createAction when createSlice would suffice
// The slice's reducers object generates the actions # ❌
// Forget to type the payload
createAction('counter/increment') // payload is unknown # ❌
// Return the prepare result without the payload wrapper
createAction('x', (text) => ({ id: 1, text })) // Wrong shape # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Payload type is unknown | No type parameter | createAction<number>(...) |
| Action not matched | Wrong type string | Verify the string matches |
| Prepare result ignored | Returned object without payload | Return { payload: ... } |
| Reducer does not handle the action | Missing addCase | Add the case to extraReducers |
.match used with Immer draft | The builder handles it | Use addCase in slices |
Real-World Examples
1. Basic action creator
const addTodo = createAction<string>('todos/add')
2. Action with no payload
const reset = createAction('app/reset')
3. Action with prepare
const addTodo = createAction('todos/add', (text: string) => ({ payload: { id: nanoid(), text } }))
4. Action with meta
const log = createAction('log', (message: string) => ({ payload: message, meta: { ts: Date.now() } }))
5. .type property
console.log(addTodo.type) // 'todos/add'
6. .match method
if (addTodo.match(action)) { console.log(action.payload) }
7. Handling in extraReducers
builder.addCase(addTodo, (state, action) => { state.items.push(action.payload) })
8. Shared action across slices
export const resetAll = createAction('app/resetAll')
9. Handling in multiple slices
// In userSlice and cartSlice:
builder.addCase(resetAll, (state) => { ... })
10. Standalone reducer
const reducer = createReducer(0, (builder) => {
builder.addCase(increment, (state, action) => state + action.payload)
})
Visual
createAction Anatomy
┌──────────────────────────────────────────────────────────────┐
│ createAction RETURNS A FUNCTION WITH PROPERTIES │
│ │
│ const addTodo = createAction<string>('todos/add') │
│ │ │
│ ├── addTodo('Buy milk') │
│ │ → { type: 'todos/add', payload: 'Buy milk' } │
│ │ │
│ ├── addTodo.type │
│ │ → 'todos/add' │
│ │ │
│ └── addTodo.match(action) │
│ → true if action.type === 'todos/add' │
│ │
│ The .match method narrows the action to the payload type. │
└──────────────────────────────────────────────────────────────┘
prepare Callback
┌──────────────────────────────────────────────────────────────┐
│ prepare TRANSFORMS ARGUMENTS INTO THE PAYLOAD │
│ │
│ createAction('todos/add', (text: string) => ({ │
│ payload: { id: nanoid(), text, createdAt: Date.now() } │
│ })) │
│ │
│ Component dispatches: addTodo('Buy milk') │
│ │ │
│ ▼ │
│ prepare runs: generates id and createdAt │
│ │ │
│ ▼ │
│ Action: { │
│ type: 'todos/add', │
│ payload: { id: '...', text: 'Buy milk', createdAt: ... } │
│ } │
│ │
│ The reducer receives the full payload object. │
└──────────────────────────────────────────────────────────────┘
Shared Action Across Slices
┌──────────────────────────────────────────────────────────────┐
│ ONE ACTION, MULTIPLE SLICES │
│ │
│ actions.ts: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ export const resetAll = createAction('app/resetAll') │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ userSlice.extraReducers cartSlice.extraReducers │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ builder.addCase( │ │ builder.addCase( │ │
│ │ resetAll, │ │ resetAll, │ │
│ │ (state) => { ... } │ │ (state) => { ... } │ │
│ │ ) │ │ ) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ The same action resets both slices. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | createAction(type, prepare?) |
| Type parameter | Payload type |
| Returns | Action creator with .type and .match |
prepare callback | Transforms arguments into payload/meta |
.type | The type string |
.match(action) | Type guard for the action type |
| Use in reducers | builder.addCase(actionCreator, reducer) |
| Shared actions | Define in a module, handle in extraReducers |
| Alternative | createSlice reducers for feature-local actions |
Key takeaways:
createActiongenerates the action creator and the type string from a single call. The returned function creates the action, and it carries.typeand.matchproperties that the reducer can use without a separate constant .- The
.matchmethod is a type guard. It narrows the action to the payload type when it returnstrue, which is useful in standalone reducers andifchains. The builder’saddCaseuses the same mechanism internally. - The
preparecallback transforms the arguments into the payload. It runs at dispatch time and can generate an ID, a timestamp, or ametafield. The action creator’s signature is determined by thepreparefunction, not the payload type . createActionis for shared actions and standalone reducers. ThecreateSlicereducersobject is the recommended way to define feature-local actions.createActionis used when the action is handled by multiple slices or when the reducer is not part of a slice .- The
extraReducersbuilder handles shared actions. Each slice that cares about the action adds a case with the action creator as the argument, and the payload type is inferred from the creator’s type parameter. - The
metafield carries metadata that is not part of the payload. It is useful for information that the reducer needs but that should not be stored in the state, such as the source of the action or a timestamp .
Remember: createAction is the standalone action creator. It generates the type string, the creator, and the type guard from a single call. The prepare callback handles the payload transformation, and the .match method handles the type narrowing. Use it for shared actions and standalone reducers, and use the createSlice reducers object for feature-local actions. The two mechanisms produce the same action objects, and the reducer handles both through the builder’s addCase.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!