Reducer Patterns for Loading, Success, and Error States

Model loading, success, and error in one reducer with a status field and started, succeeded, and failed actions that keep the request outside the reducer.

5 min read

A reducer can model loading, success, and error as one status field with a few actions. The reducer stays pure while an event handler or effect performs the request and dispatches the result.

This keeps the UI honest about what the app is doing at every step. The same pattern serves any async work, from fetching a list to saving a form.

One status field, four actions

Give the state a status field that is one of idle, loading, success, or error. Each action moves the status forward and carries the data or message that belongs with it. Keeping the shape small makes each transition obvious and leaves no room for contradictory flags.

index.jsindex.js
const initial = { status: "idle", data: null, error: null };
function reducer(state, action) {
  switch (action.type) {
    case "started": return { ...state, status: "loading" };
    case "succeeded": return { status: "success", data: action.data, error: null };
    case "failed": return { status: "error", data: null, error: action.message };
    default: return state;
  }
}

The succeeded case clears error and stores data, while failed clears data and stores the message. Only one outcome can be true at a time because they share one status, so the state can never report success with an error still attached.

Dispatch from the request flow

The reducer never calls fetch. Instead, a handler starts the request, waits for it, and dispatches the outcome.

index.jsindex.js
async function handleLoad() {
  dispatch({ type: "started" });
  try {
    const data = await fetchPosts();
    dispatch({ type: "succeeded", data });
  } catch (err) {
    dispatch({ type: "failed", message: err.message });
  }
}

Dispatching started shows loading, succeeded stores the posts, and failed stores the error message. Because the reducer only reacts to these actions, it stays pure and easy to test. If the component unmounts before the response arrives, cancel or ignore the stale result so a late dispatch does not update a screen that is gone.

Render each state

Read the status and render a matching view. An empty list is separate from loading, so a successful empty result still shows its own message. Branching on one value keeps the render function short.

App.jsxApp.jsx
if (state.status === "loading") return <p>Loading posts...</p>;
if (state.status === "error") return <p role="alert">{state.error}</p>;
return (
  <ul>
    {state.data.map((post) => <li key={post.id}>{post.title}</li>)}
  </ul>
);

While loading, the user sees a status line. On error, the alert paragraph announces the message to screen readers. On success, the list renders with a stable key per post.

The idle state before any request can reuse the same list with an empty prompt, so the screen never looks broken before the first load.

Why a status beats many booleans

Separate flags like isLoading, hasError, and hasData can contradict each other. A single status makes those contradictions impossible and gives you one place to log transitions. One status also turns a flaky request into a readable sequence of started, succeeded, and failed events.

Read why reducers must be pure to see why the request stays outside, or handle loading, error, empty, and success states for the full UI treatment.

Rune AI

Rune AI

Key Insights

  • Model loading, success, and error as one status field.
  • Dispatch started, succeeded, and failed from the request flow.
  • Keep the fetch out of the reducer.
  • Render each state with clear UI.
  • Start with idle before any request begins.
RunePowered by Rune AI

Frequently Asked Questions

Why use a status field instead of separate booleans?

One status value makes invalid combinations impossible, such as loading and success at the same time. Separate booleans can drift apart.

Should the fetch live inside the reducer?

No. A reducer must be pure. Start the request in an event handler or effect and dispatch the result as an action.

What does the initial status start as?

Start with idle. The status moves to loading when the request begins, then to success or error when it settles.

Conclusion

Model loading, success, and error as one status field driven by started, succeeded, and failed actions. Keep the request in the handler and let the reducer only decide the next state, which keeps every async transition visible and testable.