Skip to the content.

The request/success/fail triple is the shape of every fetch

The request/success/fail triple is the shape of every fetch

The most reliable pattern in async state management is also the most boring: for every request, model three outcomes — REQUEST, SUCCESS, and FAIL. Teams that skip it and reach for a lone isLoading boolean end up with spinners that never stop, errors that never clear, and race conditions they cannot explain. The triple is not ceremony. It is the minimum needed to represent what actually happens when you talk to a server, and once you draw it as a state machine the whole thing becomes obvious.

Three outcomes, not two

A fetch is not “loading or not.” Before it starts you are idle. When it starts you are loading. Then you are either success (with data) or error (with a message). A single isLoading flag collapses four states into two and loses information: it cannot tell “haven’t asked yet” from “asked and got nothing,” and it has nowhere to put the error. Drawn out, the legal states and the transitions between them form a small machine — and transitions that are not on the machine (a SUCCESS arriving while idle) simply cannot corrupt your state:

The request lifecycle as a state machine idle transitions to loading on REQUEST; loading transitions to success on SUCCESS or error on FAIL; success and error return to loading on a new REQUEST or retry. REQUEST SUCCESS FAIL retry idle loading success (data) error (message)
The request lifecycle: idle → loading → success/error, and back to loading on retry. The dot rides the happy path; FAIL branches down.

Model the state, then the reducer writes itself

Store a status enum plus data and error. Each of the three actions is a pure transition — and note that REQUEST clears the previous error, so a stale message never lingers under a fresh spinner:

const initial = { status: "idle", data: null, error: null };

function resource(state = initial, action) {
  switch (action.type) {
    case "REQUEST":  return { ...state, status: "loading", error: null };
    case "SUCCESS":  return { status: "success", data: action.data, error: null };
    case "FAIL":     return { ...state, status: "error", error: action.error };
    default:         return state;
  }
}

Components then read status and render the matching state — they never juggle flags:

function UserList({ status, data, error }) {
  if (status === "loading") return <Spinner />;
  if (status === "error")   return <Error message={error} onRetry={reload} />;
  if (status === "success") return <List items={data} />;
  return <Idle />;                     // haven't asked yet — distinct from "empty"
}

Dispatch the triple around the fetch

The three actions bracket the async call. Because reducers must stay pure, the await lives in a thunk (or saga), not in the reducer:

const load = (url) => async (dispatch) => {
  dispatch({ type: "REQUEST" });
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    dispatch({ type: "SUCCESS", data: await res.json() });
  } catch (err) {
    dispatch({ type: "FAIL", error: err.message });
  }
};

The triple gives races a place to live

Model the request as a first-class thing and the classic race — the user searches “cat”, then “cats”, and the slower “cat” response lands last and overwrites the newer results — becomes representable. Tag each request and let SUCCESS ignore a result that is not the latest:

let latest = 0;
const search = (q) => async (dispatch) => {
  const id = ++latest;                       // this request's ticket
  dispatch({ type: "REQUEST" });
  const data = await fetchResults(q);
  if (id !== latest) return;                 // a newer search started — drop this
  dispatch({ type: "SUCCESS", data });
};

A lone boolean has no way to express “this response is stale”; the triple does, because the request was a real, identifiable event rather than a flag flip.

Every data-fetching library you might adopt implements exactly this triple under the hood, exposing isLoading, data, and error because those are the three outcomes that matter. Whether you hand-roll it or adopt a library, model the request as three outcomes, not one flag — the state machine above is the whole idea. The action-creators exercise builds the triple’s actions, and retry-with-backoff layers a real failure policy onto the error state.