ReactAsync Logic with Thunks

Async Logic with Thunks

Redux reducers must be pure synchronous functions. But real applications need to fetch data, call APIs, and run conditional logic before dispatching actions. Thunks are the standard Redux solution: a thunk is a function that returns another function, which receives dispatch and getState and can run any async code before dispatching actions. Redux Toolkit ships createAsyncThunk to generate thunks with minimal boilerplate.

createAsyncThunk Signature

TS
import { createAsyncThunk } from '@reduxjs/toolkit'

const myThunk = createAsyncThunk(
  // 1. Action type prefix — RTK appends /pending, /fulfilled, /rejected
  'featureName/actionName',

  // 2. Payload creator — receives (arg, thunkAPI) and must return a value or throw
  async (arg, thunkAPI) => {
    // arg    → whatever you pass when dispatching: dispatch(myThunk(arg))
    // thunkAPI.dispatch     → dispatch further actions
    // thunkAPI.getState     → read current store state
    // thunkAPI.rejectWithValue → return a structured rejection instead of throwing
    // thunkAPI.signal       → AbortSignal for request cancellation
    const result = await someAsyncOperation(arg)
    return result   // becomes action.payload in the fulfilled case
  }
)
A Complete fetchUsers Thunk

Here is a realistic implementation: fetch a list of users, handle HTTP errors, and surface structured error messages to the UI via rejectWithValue:

TS
// src/features/users/usersSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'

// ── Types ──────────────────────────────────────────────────────────────
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

interface UsersState {
  items: User[]
  selectedId: number | null
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  error: string | null
}

// ── Thunk ──────────────────────────────────────────────────────────────
export const fetchUsers = createAsyncThunk<
  User[],             // fulfilled payload type
  void,               // arg type (no arg here)
  { rejectValue: string }  // type for rejectWithValue
>(
  'users/fetchAll',
  async (_, thunkAPI) => {
    try {
      const res = await fetch('/api/users', {
        signal: thunkAPI.signal,   // supports cancellation via thunk.abort()
      })

      if (!res.ok) {
        // rejectWithValue returns a structured rejection that lands in
        // action.payload (not action.error) in the rejected case
        return thunkAPI.rejectWithValue(
          `Server error: ${res.status} ${res.statusText}`
        )
      }

      return res.json()
    } catch (err) {
      if (err instanceof Error) {
        return thunkAPI.rejectWithValue(err.message)
      }
      return thunkAPI.rejectWithValue('Unknown error')
    }
  }
)

// ── Slice ──────────────────────────────────────────────────────────────
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    items: [],
    selectedId: null,
    status: 'idle',
    error: null,
  } as UsersState,

  // Sync reducers live here
  reducers: {
    selectUser(state, action: PayloadAction<number>) {
      state.selectedId = action.payload
    },
    clearUsers(state) {
      state.items = []
      state.status = 'idle'
    },
  },

  // Async thunk actions are handled in extraReducers
  extraReducers: builder => {
    builder
      .addCase(fetchUsers.pending, state => {
        state.status = 'loading'
        state.error  = null
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items  = action.payload
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed'
        // action.payload is the rejectWithValue string (typed above)
        // action.error.message is the fallback for unexpected throws
        state.error = action.payload ?? action.error.message ?? 'Fetch failed'
      })
  },
})

export const { selectUser, clearUsers } = usersSlice.actions
export default usersSlice.reducer
Using the Thunk in a Component

TSX
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../../store/hooks'
import { fetchUsers, selectUser } from './usersSlice'

export function UserList() {
  const dispatch = useAppDispatch()
  const { items, status, error } = useAppSelector(state => state.users)

  useEffect(() => {
    // Only fetch once — skip if we already have data
    if (status === 'idle') {
      dispatch(fetchUsers())
    }
  }, [status, dispatch])

  // The thunk returns a promise — you can await it for post-dispatch logic
  async function handleRefresh() {
    try {
      await dispatch(fetchUsers()).unwrap()
      console.log('Refreshed successfully')
    } catch (err) {
      console.error('Refresh failed:', err)
    }
  }

  if (status === 'loading') return <p>Loading…</p>
  if (status === 'failed')  return <p style={{ color: 'red' }}>{error}</p>

  return (
    <>
      <button onClick={handleRefresh}>Refresh</button>
      <ul>
        {items.map(user => (
          <li key={user.id} onClick={() => dispatch(selectUser(user.id))}>
            {user.name} — {user.email}
          </li>
        ))}
      </ul>
    </>
  )
}
Note
dispatch(thunk).unwrap() re-throws on rejection and returns the payload on fulfillment. This gives you a Promise-based API for responding to success/failure inline in your component.
thunkAPI.dispatch and thunkAPI.getState

Thunks can orchestrate multiple dispatches and read current state before deciding what to do. This is their superpower over simple async functions:

TS
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from '../../store'

// Typed thunk with access to dispatch and state
export const submitOrder = createAsyncThunk<
  { orderId: string },
  { items: CartItem[] },
  { state: RootState; dispatch: AppDispatch }
>(
  'orders/submit',
  async ({ items }, thunkAPI) => {
    // Read state to get auth token
    const token = thunkAPI.getState().auth.token
    if (!token) {
      return thunkAPI.rejectWithValue('Not authenticated')
    }

    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ items }),
    })

    if (!res.ok) return thunkAPI.rejectWithValue('Order failed')

    const order = await res.json()

    // Dispatch a separate action to clear the cart after success
    thunkAPI.dispatch(clearCart())

    return order
  }
)
Cancellation with thunkAPI.signal

Every thunk receives an AbortSignal via thunkAPI.signal. Pass it to fetch to cancel in-flight requests when the user navigates away or when the component unmounts:

TS
export const searchProducts = createAsyncThunk(
  'products/search',
  async (query: string, thunkAPI) => {
    const res = await fetch(`/api/products?q=${query}`, {
      signal: thunkAPI.signal,  // cancelled if thunk.abort() is called
    })
    return res.json()
  }
)

// In a component with a live search input:
useEffect(() => {
  const thunkResult = dispatch(searchProducts(query))

  // Cancel the in-flight request when the query changes
  return () => {
    thunkResult.abort()
  }
}, [query, dispatch])
Error Handling Patterns
Warning
Always use rejectWithValue for server errors — don't just throw. If you throw, action.payload is undefined and action.error.message is a serialized string. With rejectWithValue, you control exactly what lands in action.payload.

TS
// ── Pattern 1: structured error object ────────────────────────────────
export const createUser = createAsyncThunk<
  User,
  NewUser,
  { rejectValue: { field: string; message: string }[] }
>(
  'users/create',
  async (newUser, thunkAPI) => {
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(newUser),
    })

    if (res.status === 422) {
      const errors = await res.json()  // [{ field: 'email', message: 'taken' }]
      return thunkAPI.rejectWithValue(errors)
    }

    if (!res.ok) {
      return thunkAPI.rejectWithValue([{ field: 'general', message: 'Server error' }])
    }

    return res.json()
  }
)

// In the slice, errors is typed as { field, message }[]
.addCase(createUser.rejected, (state, action) => {
  state.validationErrors = action.payload ?? []
})
Thunks vs RTK Query
  • Use thunks for imperative async logic: multi-step workflows, conditional dispatches, operations that aren't simply "fetch and cache".

  • Use RTK Query for declarative data fetching: GET/POST/PUT/DELETE endpoints, automatic caching, background refetch, cache invalidation.

  • They complement each other — use RTK Query for standard CRUD, thunks for checkout flows, auth sequences, and complex multi-dispatch operations.

  • thunkAPI.dispatch and thunkAPI.getState are only available in thunks — RTK Query endpoints don't have access to them directly.

Tip
When you find yourself writing the same pending/fulfilled/rejected boilerplate for every API call, switch those endpoints to RTK Query. Save thunks for the logic that needs to read state, coordinate multiple dispatches, or perform imperative side effects.