Redux Promise Middleware: Automatic Pending / Fulfilled / Rejected

redux-promise-middleware removes the boilerplate of manual three-phase dispatch. Pass a Promise as the payload and it auto-dispatches PENDING, FULFILLED, and REJECTED for you.

June 28, 20266 min read

Writing custom middleware showed the dispatch chain. Every action passes through middleware before it reaches the reducer. Redux Thunk exploits that to let action creators return functions. Redux Promise Middleware exploits the same slot from a different angle: it lets action creators return a plain object whose payload is a Promise, and handles all three async phases automatically.

The Problem with Thunk Boilerplate

With Thunk, every async operation requires three manual dispatches, a try/catch, and three action type constants:

JavaScript
export const fetchTasks = () => async (dispatch) => { dispatch({ type: FETCH_TASKS_REQUEST }); try { const response = await axios.get('http://localhost:7000/tasks'); dispatch({ type: FETCH_TASKS_SUCCESS, payload: response.data }); } catch (error) { dispatch({ type: FETCH_TASKS_ERROR, payload: error.message }); } };

That is fine for one operation. Across ten operations it becomes mechanical repetition.

Redux Promise Middleware eliminates the repetition. You return one action object with a Promise as the payload. The middleware detects the Promise, dispatches _PENDING immediately, then dispatches _FULFILLED or _REJECTED when the Promise settles.

Installation and Store Setup

Bash
npm install redux-promise-middleware

Update the store at src/store/index.js:

JavaScript
import { createStore, applyMiddleware } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension'; import thunk from 'redux-thunk'; import promise from 'redux-promise-middleware'; import { createLogger } from 'redux-logger'; import allReducers from '../reducers'; const middleware = [thunk, promise, createLogger()]; const store = createStore( allReducers, composeWithDevTools(applyMiddleware(...middleware)) ); export default store;

Two things are worth noting here. First, Thunk and redux-promise-middleware solve the same problem, so you can use both simultaneously and pick the right one per operation. Second, collecting middleware into an array first and then spreading into applyMiddleware makes it easy to add or remove entries without touching the createStore call.

How It Works

Give the action creator an action object with type and a payload that is a Promise:

JavaScript
export const fetchTasks = () => ({ type: 'FETCH_TASKS', payload: axios.get('http://localhost:7000/tasks'), });

The middleware intercepts this, sees a Promise in payload, and automatically dispatches three actions:

PhaseDispatched action typeWhen
Request startsFETCH_TASKS_PENDINGImmediately
Request succeedsFETCH_TASKS_FULFILLEDPromise resolves
Request failsFETCH_TASKS_REJECTEDPromise rejects

The _PENDING, _FULFILLED, and _REJECTED suffixes are fixed. They are the middleware's convention, not something you define.

No async/await. No try/catch. No explicit dispatch calls. The action creator is a plain function that returns a plain object.

Updating Action Types

Replace the three-constant pattern with the new suffixes in constants/action-types.js:

JavaScript
// Before (Thunk) export const FETCH_TASKS_REQUEST = 'FETCH_TASKS_REQUEST'; export const FETCH_TASKS_SUCCESS = 'FETCH_TASKS_SUCCESS'; export const FETCH_TASKS_ERROR = 'FETCH_TASKS_ERROR'; // After (Redux Promise) export const FETCH_TASKS_PENDING = 'FETCH_TASKS_PENDING'; export const FETCH_TASKS_FULFILLED = 'FETCH_TASKS_FULFILLED'; export const FETCH_TASKS_REJECTED = 'FETCH_TASKS_REJECTED';

Apply the same rename for CREATE_TASK and DELETE_TASK.

Updating Action Creators

All three action creators simplify to the same one-liner pattern:

JavaScript
export const fetchTasks = () => ({ type: 'FETCH_TASKS', payload: axios.get('http://localhost:7000/tasks'), }); export const createTask = (newTask) => ({ type: 'CREATE_TASK', payload: axios.post('http://localhost:7000/tasks', newTask), }); export const deleteTask = (taskId) => ({ type: 'DELETE_TASK', payload: axios.delete(`http://localhost:7000/tasks/${taskId}`), });

Axios returns a Promise immediately when called. That Promise becomes the payload. No await, no async, no function wrapping. The middleware does the rest.

Updating the Reducer

The reducer switches on the new action types. The payload in FULFILLED is the full Axios response object, not the response body. Read action.payload.data to get the actual data:

JavaScript
export function tasksReducer(state = initialState, action) { switch (action.type) { case actionTypes.FETCH_TASKS_PENDING: return { ...state, loading: true, error: '' }; case actionTypes.FETCH_TASKS_FULFILLED: return { data: action.payload.data, loading: false, error: '' }; case actionTypes.FETCH_TASKS_REJECTED: return { ...state, loading: false, error: action.payload.message }; case actionTypes.CREATE_TASK_PENDING: return { ...state, loading: true }; case actionTypes.CREATE_TASK_FULFILLED: return { data: [...state.data, action.payload.data], loading: false, error: '', }; case actionTypes.CREATE_TASK_REJECTED: return { ...state, loading: false, error: action.payload.message }; // DELETE cases covered below default: return state; } }

The Delete Gotcha: ID From the URL

Delete is the tricky case. A DELETE request returns an empty response body. There is no task id in action.payload.data. But Axios attaches the full request config to the response at payload.config.url, and that URL contains the id:

Plain text
http://localhost:7000/tasks/51206 ↑ this part

Extract it with a string slice:

JavaScript
case actionTypes.DELETE_TASK_FULFILLED: { const url = action.payload.config.url; const deletedId = Number(url.substr(url.lastIndexOf('/') + 1)); return { ...state, data: state.data.filter((task) => task.id !== deletedId), loading: false, }; }

lastIndexOf('/') finds the last slash. +1 skips past it. Number(...) converts the string id to a number for a clean equality check in filter.

This is not elegant, but it is the standard workaround when the server returns an empty body on delete and the middleware does not surface the id through any other channel.

Components Are Unchanged

Nothing in the component layer changes. The component still calls dispatch(actions.fetchTasks()) inside a useEffect exactly as shown in the Thunk post and reads state.tasks via useSelector. The middleware sits between the action creator and the reducer. Components see neither.

Thunk vs Redux Promise

Redux ThunkRedux Promise Middleware
Action creator returnsA functionA plain object
Async phasesManual dispatchAutomatic (_PENDING, _FULFILLED, _REJECTED)
Error handlingExplicit try/catchAutomatic on Promise rejection
Code volumeMoreLess
FlexibilityHigher (arbitrary logic)Lower (Promise payloads only)

Use Thunk when you need conditional logic, multiple dispatches, or getState inside the action creator. Use redux-promise-middleware when the action creator's entire job is firing an HTTP request and waiting for the response.

Redux Promise Middleware intercepts the Promise payload and auto-dispatches PENDING, FULFILLED, and REJECTED to the reducer ExpandRedux Promise Middleware intercepts the Promise payload and auto-dispatches PENDING, FULFILLED, and REJECTED to the reducer

The Essentials

  1. The action creator returns a plain object. The payload is a Promise (the return value of axios.get, axios.post, etc.). No async/await or explicit dispatching.
  2. The three suffixes are fixed: _PENDING, _FULFILLED, _REJECTED. The middleware prepends your type string to each. FETCH_TASKSFETCH_TASKS_PENDING, FETCH_TASKS_FULFILLED, FETCH_TASKS_REJECTED.
  3. FULFILLED payload is the Axios response object. Use action.payload.data to get the actual body. For DELETE operations where the body is empty, extract the id from action.payload.config.url using lastIndexOf('/').

Further Reading

Practice what you just read.

Fix the Promise Middleware Reducer
1 exercise