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);
The real question becomes:
Which interaction scope is currently blocked?
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>
</>
);
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?
The problem is also:
How should this specific UI control represent the blocked state?
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.
The core package owns the workflow facts:
react-action-guard
It answers questions like:
Is this scope blocked?
Which blocker affects it?
What is the top blocker?
What is the reason?
The UI package owns the control interpretation:
react-action-guard-ui
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?
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
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");
}
}
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");
}
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:
- disable the button
- 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>
);
}
The important part is this:
const { buttonState } = useGuardedButton({
scope: "profile",
blockedState: "loading",
});
The core package says:
The profile scope is blocked.
The UI package says:
For this button, blocked means loading + disabled.
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>
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>
);
}
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
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} />
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
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>
);
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>
A link needs guarded navigation behavior.
useGuardedLink returns:
linkState- a guarded
onClick reasonContentariaDescribedBy
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>
)}
</>
);
}
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>
);
}
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..." });
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
For fields, the field-oriented modes include:
hidden
helperText
description
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>}
</>
);
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>
)}
</>
);
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;
};
But real applications often have their own component prop conventions.
For example:
<MyButton
isDisabled={...}
isLoading={...}
/>
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>
);
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);
The resolved state means:
{
disabled: true,
loading: true,
ariaBusy: true,
ariaDisabled: true,
}
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);
The field becomes read-only, but not disabled.
{
disabled: false,
readOnly: true,
loading: false,
ariaBusy: undefined,
ariaDisabled: undefined,
ariaReadOnly: true,
}
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");
}
}
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>
);
}
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
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?
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)