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