Request / Success / Failure: The Async State Pattern
Every async Redux operation needs three action types: request (loading starts), success (data arrived), failure (error caught). This pattern gives the UI a loading spinner and error message for free.
The thunk works for the happy path. The request fires, the data loads, the tasks render. What happens when the server is slow, or the request fails? Nothing good. The UI shows a stale empty list with no indication of what is happening.
The request/success/failure pattern fixes that by tracking three states for every async operation.
The Three Action Types
For every async operation, define three constants instead of one:
// constants/action-types.js
export const FETCH_TASKS_REQUEST = 'FETCH_TASKS_REQUEST';
export const FETCH_TASKS_SUCCESS = 'FETCH_TASKS_SUCCESS';
export const FETCH_TASKS_ERROR = 'FETCH_TASKS_ERROR';- REQUEST fires as soon as the HTTP call starts. Use it to show a loading spinner.
- SUCCESS fires when the response arrives with a 2xx status. Use it to update the data and hide the spinner.
- ERROR fires when Axios throws (4xx or 5xx response, or network failure). Use it to show an error message.
Updating the Initial State
The reducer's initial state needs three properties to reflect all three phases:
const initialState = {
data: [],
loading: false,
error: '',
};data holds the actual task array. loading drives the spinner. error holds any error message.
Updating the Reducer
export function tasksReducer(state = initialState, action) {
switch (action.type) {
case actionTypes.FETCH_TASKS_REQUEST:
return { data: [], loading: true, error: '' };
case actionTypes.FETCH_TASKS_SUCCESS:
return { data: action.payload, loading: false, error: '' };
case actionTypes.FETCH_TASKS_ERROR:
return { ...state, loading: false, error: action.payload };
// ... CREATE_TASK, DELETE_TASK cases ...
default:
return state;
}
}Each case maps cleanly to one phase of the async operation.
Updating the Thunk
Wrap the request in try/catch and dispatch each phase:
export const fetchTasks = () => async (dispatch) => {
dispatch({ type: actionTypes.FETCH_TASKS_REQUEST });
try {
const response = await fetch('http://localhost:7000/tasks');
const data = await response.json();
dispatch({ type: actionTypes.FETCH_TASKS_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: actionTypes.FETCH_TASKS_ERROR, payload: error.message });
}
};Before the request, FETCH_TASKS_REQUEST sets loading: true. On success, FETCH_TASKS_SUCCESS stores the data. On network failure or non-2xx response, FETCH_TASKS_ERROR stores the error message.
Updating the Component
The component reads from state.tasks, which is now the object { data, loading, error }. Access each property explicitly:
const tasks = useSelector((state) => state.tasks);
const filteredTasks = (tasks.data || []).filter((task) =>
task.taskTitle.toLowerCase().includes(search.toLowerCase())
);Render the loading and error states conditionally:
{tasks.loading && <i className="fas fa-spinner fa-spin" />}
{tasks.error && <h2 className="error-message">{tasks.error}</h2>}The spinner appears from the moment FETCH_TASKS_REQUEST is dispatched until SUCCESS or ERROR resolves it. If you want to test the spinner, run the JSON server with an artificial delay: json-server --delay 3000.
Applying the Pattern to CREATE and DELETE
Every async operation gets the same three action types. For createTask:
export const CREATE_TASK_REQUEST = 'CREATE_TASK_REQUEST';
export const CREATE_TASK_SUCCESS = 'CREATE_TASK_SUCCESS';
export const CREATE_TASK_ERROR = 'CREATE_TASK_ERROR';The action creator dispatches request before the POST, success with the server-returned task, and error in the catch block. The reducer handles each case. This pattern scales uniformly. Every HTTP operation in the app follows the same structure.
ExpandRequest, success, and failure action types covering the three phases of every async operation
The Essentials
- Three action types per operation: REQUEST (loading starts), SUCCESS (data arrived), ERROR (request failed). This pattern gives the UI everything it needs to communicate async status.
- Initial state shape:
{ data: [], loading: false, error: '' }. Components readstate.tasks.data,state.tasks.loading,state.tasks.errorrather than assumingstate.tasksis an array. try/catchin the thunk is mandatory for the error case. Axios throws on 4xx/5xx. Fetch does not. With Fetch you must checkresponse.okmanually and throw to reach the catch block.
Further Reading and Watching
- Redux Async Logic and Side Effects: the official patterns guide
- json-server on npm: fast mock REST API used for testing in this series
reducers/tasks.json GitHub: the final reducer with all REQUEST/SUCCESS/ERROR cases for fetch, create, and deleteactions/tasks.json GitHub: all three async thunks with try/catch dispatch pattern
Keep reading