DEV Community

Cover image for When disabled is not enough: Building guarded UI state in React
Oleksii Kyrychenko
Oleksii Kyrychenko

Posted on

When disabled is not enough: Building guarded UI state in React

A continuation of the isLoading discussion: why blocking a workflow and representing that state in the UI are two different problems.

In the previous article, I wrote about why local isLoading starts to break down in complex React applications.

The main idea was not that isLoading is bad.

It is not.

The problem is that isLoading is often too local for a workflow that is no longer local.

A boolean can tell one component that something is pending.

It cannot tell different controls how they should behave while the same workflow is pending.

A single async action may affect:

  • the submit button that started it
  • form fields that should not be edited during the request
  • navigation links that should be temporarily blocked
  • toolbar actions that should not run at the same time
  • a panel or section that should expose why it is unavailable

At some point, the real question is no longer:

const [isLoading, setIsLoading] = useState(false);
Enter fullscreen mode Exit fullscreen mode

The real question becomes:

Which interaction scope is currently blocked?
Enter fullscreen mode Exit fullscreen mode

That is the reason I started working on react-action-guard.

It gives async workflows a named scope and lets other parts of the UI react to that scope.

But after solving that problem, I ran into the next one.

Once a scope is blocked, what should the UI actually do with that information?

Should a button be disabled?

Should it show loading?

Should a field become disabled or read-only?

Should a link prevent navigation?

Should a section expose a reason to screen readers?

That is a different problem.

And that is the problem behind @okyrychenko-dev/react-action-guard-ui.


Blocking is not the same as disabling

A blocked workflow is a fact.

A disabled button is only one possible UI interpretation of that fact.

This distinction matters.

Consider this simple example:

const isBlocked = useIsBlocked("profile");

return (
  <>
    <button disabled={isBlocked}>Save</button>
    <input disabled={isBlocked} />
    <a aria-disabled={isBlocked} href="/settings">
      Settings
    </a>
  </>
);
Enter fullscreen mode Exit fullscreen mode

This is already better than scattered local loading flags.

But it still treats every control as if it had the same semantics.

They do not.

A button can be disabled.

A field may need to stay focusable and preserve its value.

A link does not have a native disabled attribute.

A group can describe that a region is unavailable, but it does not automatically disable its children.

And if the UI is blocked, users may need to know why.

So the problem is not only:

Is this scope blocked?
Enter fullscreen mode Exit fullscreen mode

The problem is also:

How should this specific UI control represent the blocked state?
Enter fullscreen mode Exit fullscreen mode

That is where a UI state layer becomes useful.


Core state vs UI state

I like to think about this separation like this:

Core state describes what is happening.
UI state describes how a control should represent it.
Enter fullscreen mode Exit fullscreen mode

The core package owns the workflow facts:

react-action-guard
Enter fullscreen mode Exit fullscreen mode

It answers questions like:

Is this scope blocked?
Which blocker affects it?
What is the top blocker?
What is the reason?
Enter fullscreen mode Exit fullscreen mode

The UI package owns the control interpretation:

react-action-guard-ui
Enter fullscreen mode Exit fullscreen mode

It answers questions like:

Should this button be disabled?
Should it show loading?
Should this field be read-only?
Should this link prevent navigation?
Should the reason be visible or only described?
Enter fullscreen mode Exit fullscreen mode

That separation keeps the core package headless.

It also keeps the UI package independent from a specific component library.

It does not render components.

It returns UI-ready state.


Getting started

npm install @okyrychenko-dev/react-action-guard-ui @okyrychenko-dev/react-action-guard
Enter fullscreen mode Exit fullscreen mode

The UI package reads blocking state from react-action-guard. Depending on your package manager, you may also need to install the core package peer dependencies, such as zustand.

The package is intentionally not tied to MUI, HeroUI, Radix, or any other component library.

The goal is to build small wrappers around your own controls.


A real async action

Let's start with a small API.

type ProfilePayload = {
  firstName: string;
  lastName: string;
};

async function saveProfile(payload: ProfilePayload): Promise<void> {
  const response = await fetch("/api/profile", {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error("Failed to save profile");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can wrap it with useAsyncAction from the core package.

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

function useSaveProfileAction() {
  return useAsyncAction("save-profile", "profile");
}
Enter fullscreen mode Exit fullscreen mode

useAsyncAction does not render anything.

It only creates a blocker while the async operation is running.

That blocker belongs to the "profile" scope.

Now the UI can react to that scope.


Guarding a button

A button usually has two common interpretations of blocked state:

  1. disable the button
  2. show a loading state and disable the button

With useGuardedButton, that mapping becomes explicit.

import { useAsyncAction } from "@okyrychenko-dev/react-action-guard";
import { useGuardedButton } from "@okyrychenko-dev/react-action-guard-ui";

type ProfilePayload = {
  firstName: string;
  lastName: string;
};

interface SaveProfileButtonProps = {
  payload: ProfilePayload;
};

export function SaveProfileButton({ payload }: SaveProfileButtonProps) {
  const save = useAsyncAction("save-profile", "profile");

  const { buttonState } = useGuardedButton({
    scope: "profile",
    blockedState: "loading",
  });

  const handleClick = async () => {
    await save(() => saveProfile(payload));
  };

  return (
    <button
      type="button"
      disabled={buttonState.disabled}
      aria-busy={buttonState.ariaBusy}
      onClick={handleClick}
    >
      {buttonState.loading ? "Saving..." : "Save profile"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The important part is this:

const { buttonState } = useGuardedButton({
  scope: "profile",
  blockedState: "loading",
});
Enter fullscreen mode Exit fullscreen mode

The core package says:

The profile scope is blocked.
Enter fullscreen mode Exit fullscreen mode

The UI package says:

For this button, blocked means loading + disabled.
Enter fullscreen mode Exit fullscreen mode

That is the difference.


Why not just write this manually?

You can.

For small components, you probably should.

const isBlocked = useIsBlocked("profile");

<button disabled={isBlocked}>
  Save
</button>
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with this.

But once you start repeating the same logic across buttons, fields, links, and groups, the mapping becomes inconsistent.

One button uses disabled.

Another button uses aria-busy.

A field becomes disabled, but maybe it should have been read-only.

A link adds aria-disabled, but still navigates.

A blocked region visually looks unavailable but does not expose a reason.

The goal of react-action-guard-ui is not to hide simple React logic.

The goal is to make guarded UI semantics explicit and reusable.


Using a shared scope with GuardedScopeProvider

Passing the same scope prop everywhere works, but it can become noisy.

When several controls belong to the same workflow, the UI package provides GuardedScopeProvider.

import { useState } from "react";
import { useAsyncAction } from "@okyrychenko-dev/react-action-guard";
import {
  GuardedScopeProvider,
  useGuardedButton,
  useGuardedField,
} from "@okyrychenko-dev/react-action-guard-ui";

type ProfileFormValues = {
  firstName: string;
  lastName: string;
};

interface ProfileFormProps = {
  initialValues: ProfileFormValues;
};

export function ProfileForm({ initialValues }: ProfileFormProps) {
  const [values, setValues] = useState(initialValues);

  const save = useAsyncAction("save-profile", "profile");

  const handleSave = async () => {
    await save(() => saveProfile(values));
  };

  return (
    <GuardedScopeProvider scope="profile">
      <form>
        <ProfileNameField
          value={values.firstName}
          onChange={(firstName) =>
            setValues((current) => ({
              ...current,
              firstName,
            }))
          }
        />

        <SaveButton onSave={handleSave} />
      </form>
    </GuardedScopeProvider>
  );
}

interface ProfileNameFieldProps = {
  value: string;
  onChange: (value: string) => void;
};

function ProfileNameField({ value, onChange }: ProfileNameFieldProps) {
  const { fieldState, reasonContent, ariaDescribedBy } = useGuardedField({
    blockedState: "readOnly",
    reasonMode: "helperText",
    reasonId: "profile-name-reason",
  });

  return (
    <label>
      First name

      <input
        value={value}
        readOnly={fieldState.readOnly}
        aria-readonly={fieldState.ariaReadOnly}
        aria-describedby={ariaDescribedBy}
        onChange={(event) => onChange(event.target.value)}
      />

      {reasonContent && (
        <span id="profile-name-reason">{reasonContent}</span>
      )}
    </label>
  );
}

interface SaveButtonProps = {
  onSave: () => Promise<void>;
};

function SaveButton({ onSave }: SaveButtonProps) {
  const { buttonState } = useGuardedButton({
    blockedState: "loading",
  });

  return (
    <button
      type="button"
      disabled={buttonState.disabled}
      aria-busy={buttonState.ariaBusy}
      onClick={onSave}
    >
      {buttonState.loading ? "Saving..." : "Save"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice that ProfileNameField and SaveButton do not receive scope.

They inherit it from GuardedScopeProvider.

This keeps the form readable while preserving the same blocking domain.

The scope resolution rule is simple:

explicit scope -> provider scope -> global
Enter fullscreen mode Exit fullscreen mode

So if a reusable control needs to override the provider scope, it still can.


Fields are not buttons

A common mistake is to treat fields like buttons.

<input disabled={isSaving} />
Enter fullscreen mode Exit fullscreen mode

Sometimes that is correct.

But often, readOnly is a better fit.

A disabled input is not focusable and may not participate in form submission in the same way.

A read-only input still communicates that the value exists, but cannot be edited right now.

That is why useGuardedField supports different blocked states:

disabled
readOnly
loading
none
Enter fullscreen mode Exit fullscreen mode

Example:

const { fieldState, reasonContent, ariaDescribedBy } = useGuardedField({
  scope: "profile",
  blockedState: "readOnly",
  reasonMode: "helperText",
  reasonId: "email-field-reason",
});

return (
  <label>
    Email

    <input
      value={email}
      readOnly={fieldState.readOnly}
      aria-readonly={fieldState.ariaReadOnly}
      aria-describedby={ariaDescribedBy}
      onChange={(event) => setEmail(event.target.value)}
    />

    {reasonContent && (
      <span id="email-field-reason">{reasonContent}</span>
    )}
  </label>
);
Enter fullscreen mode Exit fullscreen mode

The blocked state is the same.

The UI interpretation is different.


Links do not have a native disabled state

Links are another place where disabled={isBlocked} does not work.

This is invalid:

<a href="/settings" disabled={isBlocked}>
  Settings
</a>
Enter fullscreen mode Exit fullscreen mode

A link needs guarded navigation behavior.

useGuardedLink returns:

  • linkState
  • a guarded onClick
  • reasonContent
  • ariaDescribedBy

Example:

import { useGuardedLink } from "@okyrychenko-dev/react-action-guard-ui";

export function SettingsLink() {
  const { linkState, onClick, reasonContent, ariaDescribedBy } =
    useGuardedLink<HTMLAnchorElement>({
      scope: "profile",
      removeFromTabOrder: true,
      stopPropagationWhenBlocked: true,
      reasonMode: "description",
      reasonFallback: "Settings are unavailable while the profile is being saved",
      reasonId: "settings-link-reason",
    });

  return (
    <>
      <a
        href="/settings"
        aria-disabled={linkState.ariaDisabled}
        aria-describedby={ariaDescribedBy}
        tabIndex={linkState.tabIndex}
        onClick={onClick}
      >
        Settings
      </a>

      {reasonContent && (
        <span id="settings-link-reason">{reasonContent}</span>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the scope is blocked, the click is prevented.

If removeFromTabOrder is enabled, the link is removed from the tab order while blocked or disabled.

That gives links their own semantics instead of pretending they behave like buttons.


Groups describe regions

Sometimes the blocked state is not about a single control.

It is about a region.

For example, a form section may be temporarily unavailable while a save operation is running.

useGuardedGroup maps blocking state into container-level ARIA state.

import {
  useGuardedGroup,
  useGuardedField,
} from "@okyrychenko-dev/react-action-guard-ui";

export function ProfileSection() {
  const { groupState, reasonContent, ariaDescribedBy } = useGuardedGroup({
    scope: "profile",
    reasonMode: "description",
    reasonId: "profile-section-reason",
  });

  return (
    <section
      aria-busy={groupState.ariaBusy}
      aria-disabled={groupState.ariaDisabled}
      aria-describedby={ariaDescribedBy}
    >
      {reasonContent && (
        <p id="profile-section-reason">{reasonContent}</p>
      )}

      <ProfileNameField />
      <ProfileEmailField />
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

This does not disable every child automatically.

That is intentional.

A group can describe that the region is busy or unavailable, but each child control still needs its own correct semantics.

If you want native form-wide disabling, use a native fieldset disabled.

If you want more precise behavior, use guarded field/button hooks inside the group.


Exposing blocking reasons

A blocked UI without a reason is frustrating.

The user sees that something is unavailable, but not why.

The core package can provide the reason through the active blocker.

useAsyncAction("save-profile", "profile") automatically sets the blocker reason to "Executing save-profile".

That reason flows into the UI package as-is.

Use useBlocker directly when a workflow needs a custom reason string:

useBlocker("save-op", { scope: "profile", reason: "Saving your profile..." });
Enter fullscreen mode Exit fullscreen mode

The UI package decides how to expose that reason. reasonFallback is only used when the active blocker does not provide one.

For action-like controls, the supported reason modes are:

hidden
visible
description
Enter fullscreen mode Exit fullscreen mode

For fields, the field-oriented modes include:

hidden
helperText
description
Enter fullscreen mode Exit fullscreen mode

Example with visible reason:

const { buttonState, reasonContent } = useGuardedButton({
  scope: "checkout",
  blockedState: "loading",
  reasonMode: "visible",
  reasonFallback: "Checkout is temporarily unavailable",
});

return (
  <>
    <button disabled={buttonState.disabled} aria-busy={buttonState.ariaBusy}>
      {buttonState.loading ? "Processing..." : "Pay now"}
    </button>

    {reasonContent && <p>{reasonContent}</p>}
  </>
);
Enter fullscreen mode Exit fullscreen mode

Example with description:

const { buttonState, reasonContent, ariaDescribedBy } = useGuardedButton({
  scope: "checkout",
  blockedState: "disabled",
  reasonMode: "description",
  reasonId: "checkout-reason",
  reasonFallback: "Checkout is temporarily unavailable",
});

return (
  <>
    <button
      disabled={buttonState.disabled}
      aria-disabled={buttonState.ariaDisabled}
      aria-describedby={ariaDescribedBy}
    >
      Pay now
    </button>

    {reasonContent && (
      <span id="checkout-reason">{reasonContent}</span>
    )}
  </>
);
Enter fullscreen mode Exit fullscreen mode

This is not just a visual concern.

It is also an accessibility concern.

When the UI exposes an unavailable state, the reason should be available in a way that matches how that state is presented.


Custom state mappers

The package returns generic state like this:

type GuardedActionState = {
  disabled: boolean;
  loading: boolean;
  ariaBusy: true | undefined;
  ariaDisabled: true | undefined;
};
Enter fullscreen mode Exit fullscreen mode

But real applications often have their own component prop conventions.

For example:

<MyButton
  isDisabled={...}
  isLoading={...}
/>
Enter fullscreen mode Exit fullscreen mode

Instead of forcing the package's state shape into every component, useGuardedButton accepts a custom mapper.

const { buttonState } = useGuardedButton({
  scope: "checkout",
  blockedState: "loading",
  getButtonState: (state) => ({
    isDisabled: state.disabled,
    isLoading: state.loading,
    "aria-busy": state.ariaBusy,
  }),
});

return (
  <MyButton
    isDisabled={buttonState.isDisabled}
    isLoading={buttonState.isLoading}
    aria-busy={buttonState["aria-busy"]}
  >
    Pay now
  </MyButton>
);
Enter fullscreen mode Exit fullscreen mode

This is useful because the package does not need to know your component library.

It only knows how to interpret guarded state.

Your app owns the visual component API.


Pure resolvers

The hooks are only one layer.

The package also exposes pure resolver utilities.

That is useful for tests, custom hooks, and non-React wrappers.

import { resolveGuardedActionState } from "@okyrychenko-dev/react-action-guard-ui";

const state = resolveGuardedActionState({
  blockedState: "loading",
  isBlocked: true,
  disabled: false,
  loading: false,
});

console.log(state);
Enter fullscreen mode Exit fullscreen mode

The resolved state means:

{
  disabled: true,
  loading: true,
  ariaBusy: true,
  ariaDisabled: true,
}
Enter fullscreen mode Exit fullscreen mode

blockedState: "loading" sets both loading and disabled to true when blocked.

ariaBusy follows from loading: true.

ariaDisabled follows from disabled: true.

Both are available so you can apply whichever makes sense for your component.

For a native <button>, disabled already prevents interaction and is announced by screen readers.

aria-disabled is more useful when using a component library button that stays focusable even when logically disabled.

For fields:

import { resolveGuardedFieldState } from "@okyrychenko-dev/react-action-guard-ui";

const fieldState = resolveGuardedFieldState({
  blockedState: "readOnly",
  isBlocked: true,
  disabled: false,
  readOnly: false,
  loading: false,
});

console.log(fieldState);
Enter fullscreen mode Exit fullscreen mode

The field becomes read-only, but not disabled.

{
  disabled: false,
  readOnly: true,
  loading: false,
  ariaBusy: undefined,
  ariaDisabled: undefined,
  ariaReadOnly: true,
}
Enter fullscreen mode Exit fullscreen mode

This is the main value of a UI state layer.

It makes the interpretation testable.


A more complete example: checkout

Let's put the pieces together with a checkout flow.

The async API:

type CheckoutPayload = {
  cartId: string;
  couponCode: string;
};

async function submitCheckout(payload: CheckoutPayload): Promise<void> {
  const response = await fetch("/api/checkout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error("Checkout failed");
  }
}
Enter fullscreen mode Exit fullscreen mode

The screen:

import { useState } from "react";
import { useAsyncAction } from "@okyrychenko-dev/react-action-guard";
import {
  GuardedScopeProvider,
  useGuardedButton,
  useGuardedField,
  useGuardedGroup,
  useGuardedLink,
} from "@okyrychenko-dev/react-action-guard-ui";

interface CheckoutScreenProps = {
  cartId: string;
};

export function CheckoutScreen({ cartId }: CheckoutScreenProps) {
  const [couponCode, setCouponCode] = useState("");

  const submit = useAsyncAction("submit-checkout", "checkout");

  const handleSubmit = async () => {
    await submit(() =>
      submitCheckout({
        cartId,
        couponCode,
      }),
    );
  };

  return (
    <GuardedScopeProvider scope="checkout">
      <CheckoutPanel>
        <CouponField value={couponCode} onChange={setCouponCode} />
        <CheckoutActions onSubmit={handleSubmit} />
      </CheckoutPanel>
    </GuardedScopeProvider>
  );
}

interface CheckoutPanelProps = {
  children: React.ReactNode;
};

function CheckoutPanel({ children }: CheckoutPanelProps) {
  const { groupState, reasonContent, ariaDescribedBy } = useGuardedGroup({
    reasonMode: "description",
    reasonId: "checkout-panel-reason",
    reasonFallback: "Checkout is currently processing",
  });

  return (
    <section
      aria-busy={groupState.ariaBusy}
      aria-disabled={groupState.ariaDisabled}
      aria-describedby={ariaDescribedBy}
    >
      {reasonContent && (
        <p id="checkout-panel-reason">{reasonContent}</p>
      )}

      {children}
    </section>
  );
}

interface CouponFieldProps = {
  value: string;
  onChange: (value: string) => void;
};

function CouponField({ value, onChange }: CouponFieldProps) {
  const { fieldState, reasonContent, ariaDescribedBy } = useGuardedField({
    blockedState: "readOnly",
    reasonMode: "helperText",
    reasonId: "coupon-field-reason",
  });

  return (
    <label>
      Coupon code

      <input
        value={value}
        readOnly={fieldState.readOnly}
        aria-readonly={fieldState.ariaReadOnly}
        aria-describedby={ariaDescribedBy}
        onChange={(event) => onChange(event.target.value)}
      />

      {reasonContent && (
        <span id="coupon-field-reason">{reasonContent}</span>
      )}
    </label>
  );
}

interface CheckoutActionsProps = {
  onSubmit: () => Promise<void>;
};

function CheckoutActions({ onSubmit }: CheckoutActionsProps) {
  const { buttonState } = useGuardedButton({
    blockedState: "loading",
  });

  const {
    linkState,
    onClick: onCancelClick,
    reasonContent,
    ariaDescribedBy,
  } = useGuardedLink<HTMLAnchorElement>({
    removeFromTabOrder: true,
    reasonMode: "description",
    reasonId: "cancel-link-reason",
    reasonFallback: "You cannot leave checkout while it is being submitted",
  });

  return (
    <div>
      <button
        type="button"
        disabled={buttonState.disabled}
        aria-busy={buttonState.ariaBusy}
        onClick={onSubmit}
      >
        {buttonState.loading ? "Submitting..." : "Submit checkout"}
      </button>

      <a
        href="/cart"
        aria-disabled={linkState.ariaDisabled}
        aria-describedby={ariaDescribedBy}
        tabIndex={linkState.tabIndex}
        onClick={onCancelClick}
      >
        Back to cart
      </a>

      {reasonContent && (
        <span id="cancel-link-reason">{reasonContent}</span>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now one action creates one scoped blocker.

Different UI controls interpret that blocker differently:

Submit button -> loading + disabled
Coupon field  -> readOnly
Panel         -> aria-busy + aria-disabled
Back link     -> prevent navigation
Enter fullscreen mode Exit fullscreen mode

This is the point.

The workflow is shared.

The UI semantics are control-specific.


When you do not need this

You probably do not need react-action-guard-ui when:

  • one request affects one button
  • the loading state is entirely local
  • there is no shared blocking scope
  • the UI does not need to expose a reason
  • a simple disabled={isLoading} is clear enough
  • an abstraction would make the component harder to read

I still use local state for simple cases.

The goal is not to replace simple React.

The goal is to avoid spreading inconsistent blocking semantics across complex screens.


Final thought

The first step was to stop treating shared workflows as local loading flags.

The next step is to stop treating every blocked workflow as disabled={true}.

react-action-guard models the workflow.

react-action-guard-ui helps the UI represent it.

That is the separation I wanted:

core: what is happening?
ui: how should this control express it?
Enter fullscreen mode Exit fullscreen mode

When those two concerns are separated, the code becomes easier to reason about.

A blocker is no longer tied to one component.

And a UI control no longer has to guess how blocked state should be represented.


The source code is on GitHub. The package is available on npm.

Top comments (0)