Redux Toolkit 05 ⚛️ Dispatching Actions — Triggering updates with useDispatch
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
| Pattern | Code | Use When |
|---|---|---|
| Sync action, no payload | dispatch(increment()) | Simple state change |
| Sync action, payload | dispatch(incrementBy(5)) | State change with data |
| Action with prepare | dispatch(addTodo('text')) | Payload needs transformation |
| Thunk | dispatch(fetchUser(id)) | Async work |
| Conditional dispatch | dispatch(increment()) inside if | Based on component logic |
Thunk Lifecycle
| Action Type | Reducer | State Updates |
|---|---|---|
pending | fetchUser.pending | loading: true |
fulfilled | fetchUser.fulfilled | data, loading: false |
rejected | fetchUser.rejected | error, loading: false |
Imports
| Import | Source |
|---|---|
useAppDispatch | app/hooks |
createAsyncThunk | @reduxjs/toolkit |
nanoid | @reduxjs/toolkit |
| Action creators | Slice 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Type error on thunk dispatch | Using plain useDispatch | Use useAppDispatch |
| Action dispatched twice in StrictMode | useEffect runs twice in dev | Expected; idempotent thunks are safe |
| Payload type mismatch | Action creator called with wrong type | Check PayloadAction<T> in slice |
| Prepare callback not called | Reducer defined without prepare object | Use { prepare, reducer } structure |
| State not updating | Reducer not handling the action type | Check name and reducer names |
| Infinite loop in useEffect | Missing dependency array | Include 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
| Item | Value |
|---|---|
| Hook | useAppDispatch (typed) or useDispatch (plain) |
| Sync action | dispatch(actionCreator(payload)) |
| Thunk | dispatch(asyncThunk(arg)) |
| Prepare callback | { prepare, reducer } in slice |
| Thunk middleware | Included by configureStore by default |
| useEffect dispatch | Include dispatch in dependency array |
| Type requirement | useAppDispatch for thunk dispatch |
| Action object dispatch | Discouraged; use action creators |
Key takeaways:
- Dispatch action creators, not action objects.
dispatch(increment())uses the typed action creator fromcreateSlice.dispatch({ type: 'counter/increment' })bypasses the type checking and is fragile to renaming . - Use
useAppDispatchfor thunks. The typed hook includes the thunk middleware types. The plainuseDispatchonly accepts action objects, sodispatch(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
useEffectare the correct locations. - Include
dispatchinuseEffectdependencies.dispatchis 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.
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!