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.
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:
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
npm install redux-promise-middlewareUpdate the store at src/store/index.js:
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:
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:
| Phase | Dispatched action type | When |
|---|---|---|
| Request starts | FETCH_TASKS_PENDING | Immediately |
| Request succeeds | FETCH_TASKS_FULFILLED | Promise resolves |
| Request fails | FETCH_TASKS_REJECTED | Promise 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:
// 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:
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:
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:
http://localhost:7000/tasks/51206
↑ this partExtract it with a string slice:
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 Thunk | Redux Promise Middleware | |
|---|---|---|
| Action creator returns | A function | A plain object |
| Async phases | Manual dispatch | Automatic (_PENDING, _FULFILLED, _REJECTED) |
| Error handling | Explicit try/catch | Automatic on Promise rejection |
| Code volume | More | Less |
| Flexibility | Higher (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.
ExpandRedux Promise Middleware intercepts the Promise payload and auto-dispatches PENDING, FULFILLED, and REJECTED to the reducer
The Essentials
- The action creator returns a plain object. The
payloadis a Promise (the return value ofaxios.get,axios.post, etc.). Noasync/awaitor explicit dispatching. - The three suffixes are fixed:
_PENDING,_FULFILLED,_REJECTED. The middleware prepends yourtypestring to each.FETCH_TASKS→FETCH_TASKS_PENDING,FETCH_TASKS_FULFILLED,FETCH_TASKS_REJECTED. FULFILLEDpayload is the Axios response object. Useaction.payload.datato get the actual body. For DELETE operations where the body is empty, extract the id fromaction.payload.config.urlusinglastIndexOf('/').
Further Reading
- Async Actions With Promises - Redux Part 12: CodeWithTim explains async Redux patterns using Promises
- redux-promise-middleware on GitHub: source, docs, and lifecycle events
- Redux Async Logic and Side Effects: the official Redux guide covering Thunk, Promise, and Saga patterns side by side
Practice what you just read.