| | |

Redux Toolkit 05 ⚛️ Dispatching Actions — Triggering updates with useDispatch

⏱️ Estimated read: 13 min | |

Reading state is passive. Dispatching actions is active: a component sends a message to the store, the store passes it through the reducers, and the state updates. The useDispatch hook returns the store’s dispatch function, which accepts action creators from createSlice, thunks for async work, and plain action objects. The typed useAppDispatch hook from the store setup knows the full AppDispatch type, so dispatching a thunk does not produce a TypeScript error about an action object being expected.

This chapter covers the dispatch patterns that keep components clean and store logic centralized. It covers dispatching action creators with payloads, dispatching thunks, preparing payloads inside slices with prepare callbacks, and the distinction between dispatching from an event handler and dispatching in response to state changes. The store is already connected through the provider, and the slices are already defined; dispatch is the mechanism that triggers them.

Key point: A component should dispatch an action creator, not construct an action object. dispatch(increment()) is correct; dispatch({ type: 'counter/increment' }) bypasses the action creator and loses the type checking that createSlice provides. For async work, dispatch a thunk—a function that receives dispatch and getState—rather than performing the request in the component and dispatching the result.


Why dispatch belongs in components, not in the store

The event-source problem.

The store does not know when a user clicks a button, submits a form, or navigates to a page. Those events occur in components, and the component is the correct place to translate a user event into a dispatch. The reducer knows how to update the state, but it does not know when to update it. The dispatch call is the bridge: the component decides when, and the reducer decides how .

The async-logic problem.

An async action—fetching data, submitting a form, uploading a file—cannot be performed in a reducer because reducers must be pure functions with no side effects. The async logic belongs in a thunk, which is a function that receives dispatch and getState. The component dispatches the thunk, and the thunk performs the request and dispatches the result. This keeps the component free of async orchestration and the reducer free of side effects .

The payload-preparation problem.

Sometimes the payload needs to be transformed before it reaches the reducer: an ID generated with Date.now(), a timestamp added, a value normalized. The prepare callback in createSlice handles this transformation inside the slice, so the component dispatches a simple argument and the slice prepares the full payload. This keeps the preparation logic co-located with the reducer that uses it, rather than scattered across components .


a. Dispatching action creators

The action creators generated by createSlice are the primary dispatch targets. They accept the payload declared in the reducer’s PayloadAction<T> and return the action object that the reducer expects.

// src/features/counter/CounterControls.tsx
import { useAppDispatch } from '../../app/hooks'
import { increment, decrement, incrementByAmount } from './counterSlice'

function CounterControls() {
  const dispatch = useAppDispatch()

  return (
    <div>
      {/* No payload */}
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>

      {/* Payload: action creator accepts a number */}
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  )
}

The useAppDispatch hook returns the store’s dispatch function with the AppDispatch type. The action creators are typed, so dispatch(incrementByAmount(5)) is type-checked against PayloadAction<number>. Calling incrementByAmount('5') produces a compile-time error .

The component does not need to select any state to dispatch. It subscribes only to the dispatch function, which is stable for the lifetime of the store. A control component that only dispatches actions never re-renders due to state changes.


b. Dispatching thunks for async logic

A thunk is a function that receives dispatch and getState and can perform async work. It is dispatched like an action, but configureStore includes thunk middleware by default, so dispatching a function works without additional setup.

// src/features/user/userSlice.ts
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'

interface User { id: number; name: string }

interface UserState {
  data: User | null
  loading: boolean
  error: string | null
}

// createAsyncThunk generates three action types:
// 'user/fetch/pending', 'user/fetch/fulfilled', 'user/fetch/rejected'
export const fetchUser = createAsyncThunk(
  'user/fetch',
  async (userId: number) => {
    const response = await fetch(`/api/users/${userId}`)
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    return (await response.json()) as User
  }
)

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null } as UserState,
  reducers: {
    clearUser: (state) => {
      state.data = null
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchUser.fulfilled, (state, action: PayloadAction<User>) => {
        state.loading = false
        state.data = action.payload
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false
        state.error = action.error.message ?? 'Unknown error'
      })
  },
})

export const { clearUser } = userSlice.actions
export default userSlice.reducer

The createAsyncThunk call generates three action types automatically: pending, fulfilled, and rejected. The extraReducers builder handles these types. The component dispatches the thunk, and the thunk dispatches the lifecycle actions as the promise progresses .

// src/features/user/UserProfile.tsx
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser } from './userSlice'

function UserProfile({ userId }: { userId: number }) {
  const dispatch = useAppDispatch()
  const { data, loading, error } = useAppSelector((state) => state.user)

  useEffect(() => {
    // Dispatch the thunk; it returns a promise
    dispatch(fetchUser(userId))
  }, [dispatch, userId])

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error: {error}</p>
  if (!data) return <p>No user</p>

  return <p>{data.name}</p>
}

The dispatch(fetchUser(userId)) call returns a promise that resolves when the thunk completes. The component does not need to handle the promise because the state updates through the lifecycle actions. The useEffect dependency array includes dispatch and userId; dispatch is stable, so the effect runs only when userId changes .

The useAppDispatch type is essential here. The plain useDispatch returns a dispatch function that only accepts action objects, so dispatch(fetchUser(userId)) produces a type error because fetchUser(userId) returns a function, not an action object. The typed hook includes the thunk middleware types, so the call is valid .


c. Preparing payloads inside the slice

When the payload needs transformation before reaching the reducer, the prepare callback handles it. The reducer receives the prepared payload, and the component dispatches a simple argument.

// src/features/todos/todosSlice.ts
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit'

interface Todo { id: string; text: string; createdAt: number; done: boolean }

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] as Todo[] },
  reducers: {
    // The prepare callback runs before the reducer
    // The component dispatches addTodo('Buy milk')
    addTodo: {
      prepare(text: string) {
        return {
          payload: {
            id: nanoid(),        // Generated inside the slice
            text,                // Passed from the component
            createdAt: Date.now(), // Timestamp added here
            done: false,
          },
        }
      },
      reducer(state, action: PayloadAction<Todo>) {
        state.items.push(action.payload)
      },
    },
  },
})

export const { addTodo } = todosSlice.actions
export default todosSlice.reducer

The component dispatches addTodo('Buy milk'). The prepare callback generates the ID and timestamp and returns the full payload. The reducer receives a fully-formed Todo object. The component does not need to know about nanoid or Date.now(); the slice owns the payload construction .

function AddTodoForm() {
  const dispatch = useAppDispatch()
  const [text, setText] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (text.trim()) {
      dispatch(addTodo(text))   // Simple argument; slice prepares the payload
      setText('')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  )
}

The prepare callback is optional. When it is present, the action creator’s signature matches the prepare argument, not the reducer’s payload type. TypeScript infers this correctly: addTodo accepts a string, and the reducer’s action.payload is a Todo.


Complete Example Session

// ============================================
// PART 1: THE SLICE WITH SYNC AND ASYNC ACTIONS
// ============================================
// src/features/posts/postsSlice.ts
import { createSlice, createAsyncThunk, nanoid, type PayloadAction } from '@reduxjs/toolkit'

interface Post { id: string; title: string; body: string; createdAt: number }
interface PostsState { items: Post[]; loading: boolean; error: string | null }

// Async thunk: fetches posts from the API
export const fetchPosts = createAsyncThunk('posts/fetch', async () => {
  const response = await fetch('/api/posts')
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return (await response.json()) as Post[]
})

const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], loading: false, error: null } as PostsState,
  reducers: {
// ============================================
// PART 2: ACTION WITH PREPARE CALLBACK
// ============================================
    createPost: {
      prepare(title: string, body: string) {
        return {
          payload: { id: nanoid(), title, body, createdAt: Date.now() },
        }
      },
      reducer(state, action: PayloadAction<Post>) {
        state.items.push(action.payload)
      },
    },
// ============================================
// PART 3: SIMPLE SYNC ACTION
// ============================================
    deletePost: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((post) => post.id !== action.payload)
    },
  },
// ============================================
// PART 4: ASYNC LIFECYCLE HANDLERS
// ============================================
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.loading = false
        state.items = action.payload
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false
        state.error = action.error.message ?? 'Failed to load'
      })
  },
})

// ============================================
// PART 5: EXPORT ACTIONS AND REDUCER
// ============================================
export const { createPost, deletePost } = postsSlice.actions
export default postsSlice.reducer

// ============================================
// PART 6: DISPATCHING A SYNC ACTION
// ============================================
// src/features/posts/PostForm.tsx
import { useState } from 'react'
import { useAppDispatch } from '../../app/hooks'
import { createPost } from './postsSlice'

function PostForm() {
  const dispatch = useAppDispatch()
  const [title, setTitle] = useState('')
  const [body, setBody] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    // Dispatches createPost; the prepare callback generates id and createdAt
    dispatch(createPost(title, body))
    setTitle('')
    setBody('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <textarea value={body} onChange={(e) => setBody(e.target.value)} />
      <button type="submit">Create</button>
    </form>
  )
}

// ============================================
// PART 7: DISPATCHING A THUNK
// ============================================
// src/features/posts/PostList.tsx
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchPosts, deletePost } from './postsSlice'

function PostList() {
  const dispatch = useAppDispatch()
  const { items, loading, error } = useAppSelector((state) => state.posts)

  useEffect(() => {
    dispatch(fetchPosts())   // Thunk dispatch; returns a promise
  }, [dispatch])

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error: {error}</p>

  return (
    <ul>
      {items.map((post) => (
        <li key={post.id}>
          {post.title}
          <button onClick={() => dispatch(deletePost(post.id))}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

The seven parts demonstrate the complete dispatch surface: a slice with a prepare callback, a simple sync action, an async thunk with lifecycle handlers, and components dispatching each type. The useAppDispatch hook is used throughout, ensuring that both action objects and thunks are type-checked.


Quick Reference

Dispatch Patterns

PatternCodeUse When
Sync action, no payloaddispatch(increment())Simple state change
Sync action, payloaddispatch(incrementBy(5))State change with data
Action with preparedispatch(addTodo('text'))Payload needs transformation
Thunkdispatch(fetchUser(id))Async work
Conditional dispatchdispatch(increment()) inside ifBased on component logic

Thunk Lifecycle

Action TypeReducerState Updates
pendingfetchUser.pendingloading: true
fulfilledfetchUser.fulfilleddata, loading: false
rejectedfetchUser.rejectederror, loading: false

Imports

ImportSource
useAppDispatchapp/hooks
createAsyncThunk@reduxjs/toolkit
nanoid@reduxjs/toolkit
Action creatorsSlice file

Best Practices

✅ Do This:

// Use the typed dispatch hook
const dispatch = useAppDispatch()                              // ✅

// Dispatch action creators, not plain objects
dispatch(increment())                                          // ✅

// Dispatch thunks for async work
dispatch(fetchUser(userId))                                    // ✅

// Use prepare callbacks for payload transformation
dispatch(addTodo('text'))    // Slice generates id/timestamp    // ✅

// Include dispatch in useEffect dependencies
useEffect(() => { dispatch(fetchPosts()) }, [dispatch])        // ✅

// Dispatch from event handlers
<button onClick={() => dispatch(increment())}>+</button>      // ✅

❌ Don’t Do This:

// Construct action objects manually
dispatch({ type: 'counter/increment' })                        // ❌

// Perform async work in the component and dispatch the result
const data = await fetch(...); dispatch(setData(data))         // ❌ Use thunk

// Use plain useDispatch for thunks
const dispatch = useDispatch(); dispatch(fetchUser(id))        // ❌ Type error

// Dispatch during render
function Component() { dispatch(increment()); return ... }     // ❌

// Omit dispatch from useEffect dependencies
useEffect(() => { dispatch(fetchPosts()) }, [])                // ❌ Stale closure

// Mutate state after dispatch
dispatch(increment()); state.value = 10                        // ❌

Common Pitfalls

PitfallWhy It HappensFix
Type error on thunk dispatchUsing plain useDispatchUse useAppDispatch
Action dispatched twice in StrictModeuseEffect runs twice in devExpected; idempotent thunks are safe
Payload type mismatchAction creator called with wrong typeCheck PayloadAction<T> in slice
Prepare callback not calledReducer defined without prepare objectUse { prepare, reducer } structure
State not updatingReducer not handling the action typeCheck name and reducer names
Infinite loop in useEffectMissing dependency arrayInclude dispatch and variables

Real-World Examples

1. Dispatch sync action

dispatch(increment())

2. Dispatch with payload

dispatch(incrementByAmount(5))

3. Dispatch action with prepare

dispatch(addTodo('Buy milk'))

4. Dispatch thunk

dispatch(fetchUser(1))

5. Dispatch in useEffect

useEffect(() => { dispatch(fetchPosts()) }, [dispatch])

6. Dispatch from event handler

<button onClick={() => dispatch(decrement())}>-</button>

7. Dispatch conditional action

if (isValid) dispatch(submitForm(data))

8. Dispatch multiple actions

dispatch(increment())
dispatch(setStep(2))

9. Dispatch thunk with argument

dispatch(fetchUser(userId))

10. Dispatch with promise handling

dispatch(fetchUser(id)).unwrap().then((user) => console.log(user))

Visual

Dispatch Flow

┌──────────────────────────────────────────────────────────────┐
│  COMPONENT → DISPATCH → REDUCER → STORE → SELECTOR           │
│                                                              │
│  ┌────────────┐                                              │
│  │ Component  │                                              │
│  │ onClick    │                                              │
│  └─────┬──────┘                                              │
│        │ dispatch(increment())                               │
│        ▼                                                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ dispatch function                                      │  │
│  │   │                                                    │  │
│  │   ├── action object → reducer                          │  │
│  │   └── thunk function → thunk middleware → dispatch     │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Reducer (createSlice)                                  │  │
│  │ state.value += 1                                       │  │
│  │ → New state object                                     │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Store notifies subscribers                             │  │
│  │ → useSelector re-runs selectors                        │  │
│  │ → Components with changed values re-render             │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Thunk Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  createAsyncThunk DISPATCHES THREE ACTIONS                   │
│                                                              │
│  dispatch(fetchUser(1))                                      │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Thunk starts                                           │  │
│  │ dispatch({ type: 'user/fetch/pending' })               │  │
│  │ → reducer: loading = true                              │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ await fetch('/api/users/1')                            │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│              ┌──────────────┴──────────────┐                 │
│              │                             │                 │
│              ▼ success                     ▼ failure         │
│  ┌──────────────────────────┐  ┌──────────────────────────┐  │
│  │ dispatch({               │  │ dispatch({               │  │
│  │   type: 'user/fetch/     │  │   type: 'user/fetch/     │  │
│  │   fulfilled',            │  │   rejected',             │  │
│  │   payload: user          │  │   error: message         │  │
│  │ })                       │  │ })                       │  │
│  │ → data = user,           │  │ → error = message,       │  │
│  │   loading = false        │  │   loading = false        │  │
│  └──────────────────────────┘  └──────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Prepare Callback

┌──────────────────────────────────────────────────────────────┐
│  prepare BUILDS THE PAYLOAD BEFORE THE REDUCER RUNS          │
│                                                              │
│  Component:                                                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ dispatch(addTodo('Buy milk'))                          │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  prepare callback:                                           │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ prepare(text) {                                        │  │
│  │   return {                                             │  │
│  │     payload: {                                         │  │
│  │       id: nanoid(),        ← generated                 │  │
│  │       text,                ← from component            │  │
│  │       createdAt: Date.now(), ← generated               │  │
│  │       done: false,         ← default                   │  │
│  │     }                                                  │  │
│  │   }                                                    │  │
│  │ }                                                      │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  reducer:                                                    │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ (state, action) => { state.items.push(action.payload) }│  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
HookuseAppDispatch (typed) or useDispatch (plain)
Sync actiondispatch(actionCreator(payload))
Thunkdispatch(asyncThunk(arg))
Prepare callback{ prepare, reducer } in slice
Thunk middlewareIncluded by configureStore by default
useEffect dispatchInclude dispatch in dependency array
Type requirementuseAppDispatch for thunk dispatch
Action object dispatchDiscouraged; use action creators

Key takeaways:

  • Dispatch action creators, not action objects. dispatch(increment()) uses the typed action creator from createSlice. dispatch({ type: 'counter/increment' }) bypasses the type checking and is fragile to renaming .
  • Use useAppDispatch for thunks. The typed hook includes the thunk middleware types. The plain useDispatch only accepts action objects, so dispatch(fetchUser(id)) produces a type error .
  • Thunks belong in the slice, not the component. The component dispatches the thunk and renders based on the state. The thunk performs the async work and dispatches the lifecycle actions. This keeps the component free of async orchestration.
  • Prepare callbacks co-locate payload construction with the reducer. The component dispatches a simple argument, and the slice generates the ID, timestamp, and any other derived fields. The reducer receives a fully-formed payload .
  • Dispatch from event handlers and effects, not during render. A dispatch call in the component body runs on every render and causes an infinite loop. Event handlers and useEffect are the correct locations.
  • Include dispatch in useEffect dependencies. dispatch is stable, but including it in the dependency array satisfies the linter and documents the effect’s dependency on the store.
  • The thunk returns a promise. dispatch(fetchUser(id)) returns a promise that resolves when the thunk completes. This can be used for chaining or error handling, but the lifecycle actions are the primary mechanism for updating state.

Remember: Dispatch is the event that triggers a reducer. The component decides when the event happens, the action creator describes what happened, and the reducer decides how the state changes. Use the typed dispatch hook, dispatch action creators and thunks, and keep the async logic in the slice. The component stays focused on the user event, the slice handles the state transformation, and the store notifies the subscribers.


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!