Technology Apr 22, 2026 · 9 min read

Why `isLoading` Breaks Down in Complex React Apps

A cleaner pattern for preventing duplicate submits, blocking conflicting actions, and coordinating async workflows across React components. For a small component, a local isLoading flag is usually fine. A button starts a request, disables itself, shows a spinner, and then goes back to normal whe...

DE
DEV Community
by Oleksii Kyrychenko
Why `isLoading` Breaks Down in Complex React Apps

A cleaner pattern for preventing duplicate submits, blocking conflicting actions, and coordinating async workflows across React components.

For a small component, a local isLoading flag is usually fine.

A button starts a request, disables itself, shows a spinner, and then goes back to normal when the request finishes. Simple and easy to reason about.

But that simplicity rarely survives contact with a real application.

The moment an async action starts affecting more than one component, isLoading stops being just a loading flag and starts becoming a coordination problem.

That is where many React codebases begin to drift into:

  • scattered booleans,
  • duplicated disable logic,
  • prop drilling,
  • ad-hoc context state,
  • tightly coupled components,
  • and timing bugs that only show up under real user interaction.

isLoading is not wrong.

It is often just too local for a problem that is no longer local.

This article is about a better mental model for that class of problem: action scopes.

The real problem is coordination, not loading

Let's start with the usual pattern.

function SaveButton() {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = async () => {
    setIsLoading(true);

    try {
      await saveProfile();
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button onClick={handleClick} disabled={isLoading}>
      {isLoading ? "Saving..." : "Save"}
    </button>
  );
}

For one button, this is completely reasonable.

The trouble starts when the same async workflow needs to affect other parts of the UI.

For example:

  • while the profile is being saved, the user should not be able to submit again;
  • a destructive action such as "Delete profile" should be blocked;
  • a toolbar should disable conflicting actions;
  • navigation may need to wait for a critical operation to finish;
  • another component may need to reflect the same async state.

Now the question is no longer:

Is this button loading?

It becomes:

Which parts of the UI should react to this workflow being in progress?

That is a different problem.

And isLoading is usually too small an abstraction for it.

How boolean state starts spreading

Once an async action becomes shared, most teams reach for one of the standard fixes.

1. Lift state up

function ProfilePage() {
  const [isSaving, setIsSaving] = useState(false);

  return (
    <>
      <ProfileForm isSaving={isSaving} setIsSaving={setIsSaving} />
      <DeleteProfileButton disabled={isSaving} />
      <ProfileToolbar isSaving={isSaving} />
    </>
  );
}

This works until the parent component needs to know too much about the behavior of its children.

The async workflow is shared, but the model is still implicit and hand-wired.

2. Add more booleans

const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);

const isBlocked = isSaving || isDeleting || isPublishing;

At that point, the code is no longer expressing workflows.

It is just accumulating conditions.

3. Move everything into ad-hoc context

A context can reduce prop drilling, but it often just relocates the same boolean soup into a more centralized place.

The pain is real, but the abstraction is still missing.

A better question to ask

Instead of asking:

Which component owns this loading flag?

Ask:

Which interaction scope is currently busy?

A scope is simply a named boundary for a workflow.

Examples:

  • profile:save
  • profile:danger-zone
  • records:bulk-edit
  • checkout:payment
  • toolbar:primary-actions

Once you name the workflow, the UI can react to it declaratively:

  • one component can start the action;
  • another component can block itself based on that action;
  • a third component can show activity state for the same workflow;
  • middleware can observe the lifecycle for logs, metrics, or debugging.

This is no longer just local loading state.

It is workflow coordination.

Before: local state with implicit coupling

Here is a simplified example.

function ProfileForm() {
  const [isSaving, setIsSaving] = useState(false);

  const handleSubmit = async () => {
    setIsSaving(true);

    try {
      await saveProfile();
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <form>
      <button onClick={handleSubmit} disabled={isSaving}>
        Save profile
      </button>
    </form>
  );
}

function DeleteProfileButton() {
  return <button>Delete profile</button>;
}

This is fine until the delete button must also react to the save workflow.

Now you either:

  • lift state,
  • pass props through layers,
  • duplicate logic,
  • or invent custom synchronization.

The coupling grows, but the code still has no explicit model for the shared interaction.

After: coordinate by scope

With a scope-based model, initiation and reaction do not need to live in the same component.

import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";

function ProfileForm() {
  const runSave = useAsyncAction("save-profile", "profile:save");

  const handleSubmit = async () => {
    await runSave(async () => {
      await saveProfile();
    });
  };

  return (
    <form>
      <button onClick={handleSubmit}>Save profile</button>
    </form>
  );
}

function DeleteProfileButton() {
  const isBlocked = useIsBlocked("profile:save");

  return <button disabled={isBlocked}>Delete profile</button>;
}

The code is no longer saying:

Disable this button because some component has isLoading.

It is saying:

While the profile:save workflow is active, conflicting actions should be blocked.

That is a much better abstraction boundary.

Why this scales better

A scope-based approach improves several things at once:

  • the component that starts an action does not need to manually coordinate every component that should react to it;
  • 'profile:save' is more meaningful than isLoading because it names a workflow, not just a transient flag;
  • instead of composing large disable expressions from unrelated flags, you model shared interaction boundaries directly;
  • once actions have named scopes and lifecycles, they become much easier to observe through middleware.

If you need isolated blocking state instead of the default global store, UIBlockingProvider creates a separate store instance for part of your app.

That is especially useful for SSR, testing, or micro-frontends.

For example, react-action-guard ships a loggerMiddleware you can plug in with one line:

import { UIBlockingProvider, loggerMiddleware } from "@okyrychenko-dev/react-action-guard";

function App() {
  return (
    <UIBlockingProvider middlewares={[loggerMiddleware]}>
      <MyApplication />
    </UIBlockingProvider>
  );
}

From that point, every action start, finish, and timeout is logged automatically with the action ID, scope, reason, and duration.

A realistic example

Imagine an account settings page with:

  • a profile form,
  • a toolbar with a "Save" button,
  • a danger zone with a "Delete account" button,
  • and a small status badge that reflects ongoing work.

All of them need to react to the same save workflow.

import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";

function AccountSettingsPage() {
  return (
    <>
      <ProfileToolbar />
      <ProfileForm />
      <DangerZone />
      <SaveStatus />
    </>
  );
}

function ProfileToolbar() {
  const isSaving = useIsBlocked("profile:save");

  return (
    <div>
      <button disabled={isSaving}>Save</button>
      <button disabled={isSaving}>Publish</button>
    </div>
  );
}

function ProfileForm() {
  const runSave = useAsyncAction("save-profile", "profile:save");

  const handleSubmit = async () => {
    await runSave(async () => {
      await saveProfile();
    });
  };

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void handleSubmit();
      }}
    >
      <button type="submit">Save profile</button>
    </form>
  );
}

function DangerZone() {
  const isSaving = useIsBlocked("profile:save");

  return <button disabled={isSaving}>Delete account</button>;
}

function SaveStatus() {
  const isSaving = useIsBlocked("profile:save");

  return <span>{isSaving ? "Saving changes..." : "All changes saved"}</span>;
}

The key point is not the API call itself.

The workflow has become a first-class concept. One component starts it, other components respond to it, and none of them need to wire loading state through each other manually.

Beyond async actions: declarative blocking

useAsyncAction handles the most common case: wrapping an async function.

But sometimes you need finer control. For example, blocking navigation while there are unsaved changes:

function Editor() {
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

  useBlocker(
    "unsaved-changes",
    {
      scope: "navigation",
      reason: "You have unsaved changes",
      priority: 80,
    },
    hasUnsavedChanges
  );

  return <div>...</div>;
}

useBlocker lets you create blockers declaratively based on component state. No async function is required.

The blocker is added when hasUnsavedChanges is true, removed when it becomes false, and cleaned up automatically when the component unmounts.

When simple isLoading is still the right choice

Not every loading state needs a coordination model.

A plain local flag is still the better choice when:

  • only one component cares about the state;
  • no other UI surface needs to react to the workflow;
  • the async operation is truly local and short-lived;
  • introducing shared scopes would add more indirection than value.

If a button starts a request and only that button needs to show loading, isLoading is still the simplest and best solution.

The scope-based model starts paying off when the workflow crosses component boundaries.

Type-safe scopes with createTypedHooks

As the number of scopes in a project grows, typos become a real risk:

// Did I write "profile:save" or "profile-save" or "profileSave"?
const isBlocked = useIsBlocked("profile_save"); // silently wrong

react-action-guard ships a factory that locks scope strings to a TypeScript union:

// scopes.ts
import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";

type AppScopes =
  | "profile:save"
  | "profile:danger-zone"
  | "checkout:payment"
  | "toolbar:primary-actions";

export const {
  useAsyncAction,
  useIsBlocked,
  useBlocker,
  useBlockingInfo,
} = createTypedHooks<AppScopes>();

Now every hook call is guarded at compile time:

useIsBlocked("profile:save"); // OK
useIsBlocked("profile_save"); // TypeScript error

Once you export the typed hooks from scopes.ts, use them everywhere in your app instead of importing the generic versions directly. That way TypeScript enforces valid scope names across the entire codebase.

This becomes especially useful in larger teams, where scopes are defined once and reused across many components.

Getting started

npm install @okyrychenko-dev/react-action-guard
import {
  useAsyncAction,
  useIsBlocked,
  useBlocker,
  UIBlockingProvider,
  loggerMiddleware,
} from "@okyrychenko-dev/react-action-guard";

No provider is required for basic usage. The library works with a global store out of the box.

Wrap your app in UIBlockingProvider when you need isolated state for SSR, testing, or micro-frontends.

If this problem sounds familiar

The goal of react-action-guard is not to replace a spinner.

It is to give React applications a cleaner way to model shared async interaction boundaries — so that workflows are first-class concepts instead of accidental side effects of component state.

If you want to explore further, the code and examples are on GitHub. The package is available on npm.

DE
Source

This article was originally published by DEV Community and written by Oleksii Kyrychenko.

Read original article on DEV Community
Back to Discover

Reading List