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.

June 27, 20263 min read2 / 3

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:

JavaScript
// 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:

JavaScript
const initialState = { data: [], loading: false, error: '', };

data holds the actual task array. loading drives the spinner. error holds any error message.

Updating the Reducer

JavaScript
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:

JavaScript
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:

JSX
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:

JSX
{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:

JavaScript
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.

Request, success, and failure action types covering the three phases of every async operation ExpandRequest, success, and failure action types covering the three phases of every async operation

The Essentials

  1. 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.
  2. Initial state shape: { data: [], loading: false, error: '' }. Components read state.tasks.data, state.tasks.loading, state.tasks.error rather than assuming state.tasks is an array.
  3. try/catch in the thunk is mandatory for the error case. Axios throws on 4xx/5xx. Fetch does not. With Fetch you must check response.ok manually and throw to reach the catch block.

Further Reading and Watching