| | |

Redux Toolkit 07 ⚛️ Action Creators — Generating custom actions with createAction

⏱️ Estimated read: 13 min | |

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

FormSignaturePayload
createAction<T>(type)(payload: T) => ActionT
createAction(type, prepare)(...args) => ActionFrom prepare
createAction(type)() => Actionundefined

Action Creator Properties

PropertyTypePurpose
The function(payload) => ActionCreates the action
.typestringThe type string
.match(action)(action) => booleanType guard

prepare Return Shape

FieldPurpose
payloadThe action payload
metaMetadata not in the payload
errorMarks the action as an error

Comparison with createSlice

FeaturecreateActioncreateSlice reducers
Action creatorYesYes
ReducerNoYes
Immer draftNoYes
Use caseShared actions, standalone reducersFeature 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

PitfallWhy It HappensFix
Payload type is unknownNo type parametercreateAction<number>(...)
Action not matchedWrong type stringVerify the string matches
Prepare result ignoredReturned object without payloadReturn { payload: ... }
Reducer does not handle the actionMissing addCaseAdd the case to extraReducers
.match used with Immer draftThe builder handles itUse 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

ItemValue
FunctioncreateAction(type, prepare?)
Type parameterPayload type
ReturnsAction creator with .type and .match
prepare callbackTransforms arguments into payload/meta
.typeThe type string
.match(action)Type guard for the action type
Use in reducersbuilder.addCase(actionCreator, reducer)
Shared actionsDefine in a module, handle in extraReducers
AlternativecreateSlice reducers for feature-local actions

Key takeaways:

  • createAction generates the action creator and the type string from a single call. The returned function creates the action, and it carries .type and .match properties that the reducer can use without a separate constant .
  • The .match method is a type guard. It narrows the action to the payload type when it returns true, which is useful in standalone reducers and if chains. The builder’s addCase uses the same mechanism internally.
  • The prepare callback transforms the arguments into the payload. It runs at dispatch time and can generate an ID, a timestamp, or a meta field. The action creator’s signature is determined by the prepare function, not the payload type .
  • createAction is for shared actions and standalone reducers. The createSlice reducers object is the recommended way to define feature-local actions. createAction is used when the action is handled by multiple slices or when the reducer is not part of a slice .
  • The extraReducers builder 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 meta field 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.


Was this helpful?


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!