@okyrychenko-dev/react-action-guard-tanstack
v0.3.5
Published
TanStack Query integration for React Action Guard - seamless UI blocking for queries and mutations
Maintainers
Readme
@okyrychenko-dev/react-action-guard-tanstack
TanStack Query integration for React Action Guard - seamless UI blocking for queries and mutations
Features
- 🔄 Automatic UI blocking based on query and mutation states
- 🎯 Scope-based blocking for granular control
- 📊 Priority system for managing multiple blockers
- 💬 Dynamic reasons - different messages for different states
- 🔒 Type-safe with full TypeScript support
- 🧠 Preserves TanStack Query inference for
select,initialData, mutation variables, anduseQueriestuples - ⚡ Seamless TanStack Query integration - supports all TanStack Query hooks
- 🧹 Automatic cleanup on component unmount
- ⚙️ Stable blocker lifecycle across rerenders and React
StrictMode - 🪝 4 specialized hooks -
useBlockingQuery,useBlockingMutation,useBlockingInfiniteQuery,useBlockingQueries - 🌳 Tree-shakeable - import only what you need
- 🎨 Clean architecture - shared utilities for maintainability
Installation
npm install @okyrychenko-dev/react-action-guard-tanstack @okyrychenko-dev/react-action-guard @tanstack/react-query zustand
# or
yarn add @okyrychenko-dev/react-action-guard-tanstack @okyrychenko-dev/react-action-guard @tanstack/react-query zustand
# or
pnpm add @okyrychenko-dev/react-action-guard-tanstack @okyrychenko-dev/react-action-guard @tanstack/react-query zustandThis package requires the following peer dependencies:
- @okyrychenko-dev/react-action-guard ^1.0.1 - The core UI blocking library
- @tanstack/react-query ^5.90.10 - TanStack Query for data fetching
- React ^18.0.0 || ^19.0.0
- Zustand - State management (peer dependency of react-action-guard)
Quick Start
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
useBlockingQuery,
useBlockingMutation,
} from "@okyrychenko-dev/react-action-guard-tanstack";
import { useIsBlocked } from "@okyrychenko-dev/react-action-guard";
// Setup QueryClient
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
// Use in your components
function UserProfile() {
const query = useBlockingQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
blockingConfig: {
scope: "profile",
reason: "Loading profile...",
},
});
const isBlocked = useIsBlocked("profile");
return (
<div>
{isBlocked && <LoadingSpinner />}
{query.data && <UserInfo user={query.data} />}
</div>
);
}API Reference
Hooks
useBlockingQuery(options)
A wrapper around TanStack Query's useQuery that integrates with the UI blocking system.
Parameters:
options: UseBlockingQueryOptions<TData, TError>- All standarduseQueryoptions plus:blockingConfig: QueryBlockingConfig- Blocking configurationscope?: string | string[]- Scope(s) to blockreason?: string- Default message (default:'Loading data...')priority?: number- Priority level (default:10)timeout?: number- Auto-remove blocker after N millisecondsonTimeout?: (blockerId: string) => void- Callback when blocker is auto-removedonLoading?: boolean- Block during the initial pending state (default:true)onFetching?: boolean- Block during background refetching (default:false)onError?: boolean- Block when query fails (default:false)reasonOnLoading?: string- Message for the initial pending statereasonOnFetching?: string- Message for the background refetching statereasonOnError?: string- Message for error state
Returns: UseQueryResult<TData, TError> - Standard TanStack Query result
Example:
function MyComponent() {
const query = useBlockingQuery({
queryKey: ["users"],
queryFn: fetchUsers,
blockingConfig: {
scope: "global",
reasonOnLoading: "Loading users...",
reasonOnFetching: "Refreshing users...",
reasonOnError: "Failed to load users",
onLoading: true,
onFetching: false,
onError: true,
},
});
return <div>{/* your UI */}</div>;
}useBlockingMutation(options)
A wrapper around TanStack Query's useMutation that integrates with the UI blocking system.
Parameters:
options: UseBlockingMutationOptions<TData, TError, TVariables>- All standarduseMutationoptions plus:blockingConfig: MutationBlockingConfig- Blocking configurationscope?: string | string[]- Scope(s) to blockreason?: string- Default message (default:'Saving changes...')priority?: number- Priority level (default:30)timeout?: number- Auto-remove blocker after N millisecondsonTimeout?: (blockerId: string) => void- Callback when blocker is auto-removedonError?: boolean- Block when mutation fails (default:false)reasonOnPending?: string- Message for pending statereasonOnError?: string- Message for error state (requiresonError: true)
Returns: UseMutationResult<TData, TError, TVariables> - Standard TanStack Query result
Example:
function MyComponent() {
const mutation = useBlockingMutation({
mutationFn: createUser,
blockingConfig: {
scope: "user-form",
reasonOnPending: "Creating user...",
reasonOnError: "Failed to create user",
onError: true,
},
});
return <button onClick={() => mutation.mutate({ name: "John" })}>Create User</button>;
}useBlockingInfiniteQuery(options)
A wrapper around TanStack Query's useInfiniteQuery that integrates with the UI blocking system.
Parameters:
options: UseBlockingInfiniteQueryOptions<TData, TError, TPageParam>- All standarduseInfiniteQueryoptions plus:blockingConfig: InfiniteQueryBlockingConfig- Blocking configurationscope?: string | string[]- Scope(s) to blockreason?: string- Default message (default:'Loading more data...')priority?: number- Priority level (default:10)timeout?: number- Auto-remove blocker after N millisecondsonTimeout?: (blockerId: string) => void- Callback when blocker is auto-removedonLoading?: boolean- Block during the initial pending state (default:true)onFetching?: boolean- Block during refetching or fetching next/previous page (default:false)onError?: boolean- Block when query fails (default:false)reasonOnLoading?: string- Message for the initial pending statereasonOnFetching?: string- Message for refetching or page fetchingreasonOnError?: string- Message for error state
Returns: UseInfiniteQueryResult<TData, TError> - Standard TanStack Query result
Example:
function InfiniteList() {
const query = useBlockingInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
blockingConfig: {
scope: "post-list",
reasonOnLoading: "Loading posts...",
reasonOnFetching: "Loading more posts...",
onLoading: true,
onFetching: true,
},
});
return (
<div>
{query.data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
{query.hasNextPage && <button onClick={() => query.fetchNextPage()}>Load More</button>}
</div>
);
}useBlockingQueries(queries, blockingConfig)
A wrapper around TanStack Query's useQueries that integrates with the UI blocking system.
Parameters:
queries: Array<UseBlockingQueriesOptions>- Array of query options (same asuseQueries)blockingConfig: QueriesBlockingConfig- Unified blocking configuration for all queriesscope?: string | string[]- Scope(s) to blockreason?: string- Default message (default:'Loading queries...')priority?: number- Priority level (default:10)timeout?: number- Auto-remove blocker after N millisecondsonTimeout?: (blockerId: string) => void- Callback when blocker is auto-removedonLoading?: boolean- Block when any query is pending (default:true)onFetching?: boolean- Block when any query is refetching (default:false)onError?: boolean- Block when any query fails (default:false)reasonOnLoading?: string- Message for the pending statereasonOnFetching?: string- Message for the refetching statereasonOnError?: string- Message for error state
Returns: Array of UseQueryResult - Standard TanStack Query results
Example:
function Dashboard() {
const results = useBlockingQueries(
[
{ queryKey: ["user"], queryFn: fetchUser },
{ queryKey: ["posts"], queryFn: fetchPosts },
{ queryKey: ["comments"], queryFn: fetchComments },
],
{
scope: "dashboard",
reasonOnLoading: "Loading dashboard...",
reasonOnFetching: "Refreshing data...",
onLoading: true,
}
);
const [userQuery, postsQuery, commentsQuery] = results;
return (
<div>
<div>User: {userQuery.data?.name}</div>
<div>Posts: {postsQuery.data?.length}</div>
<div>Comments: {commentsQuery.data?.length}</div>
</div>
);
}Tree Shaking
The library is fully tree-shakeable. Import only the hooks you need to keep your bundle size small:
// Only imports the hook you need
import { useBlockingQuery } from "@okyrychenko-dev/react-action-guard-tanstack";
// Internal utilities are not bundled unless used
import { useBlockingMutation } from "@okyrychenko-dev/react-action-guard-tanstack";The package is configured with "sideEffects": false, allowing modern bundlers (Webpack, Rollup, Vite) to eliminate unused code automatically.
Bundle sizes (approximate):
- Full library: ~6.3 KB (ESM, minified)
- Single hook: ~2-3 KB (with shared utilities)
TypeScript
The package is written in TypeScript and includes full type definitions.
Type fidelity is intentionally close to native TanStack Query behavior:
useBlockingQuerypreservesselectinference andinitialDataoverload behavioruseBlockingInfiniteQuerypreserves infinite-query result typinguseBlockingMutationpreserves mutation result and variable inferenceuseBlockingQueriespreserves tuple inference for mixed query arrays- wrapper defaults follow TanStack Query's
DefaultError - all wrappers accept the optional
queryClientparameter, like the native hooks
import type {
// Hook options types
UseBlockingQueryOptions,
UseBlockingMutationOptions,
UseBlockingInfiniteQueryOptions,
UseBlockingQueriesOptions,
// Config types
QueryBlockingConfig,
MutationBlockingConfig,
InfiniteQueryBlockingConfig,
QueriesBlockingConfig,
// Base types
BaseBlockingConfig,
} from "@okyrychenko-dev/react-action-guard-tanstack";
// Usage with type parameters
interface User {
id: number;
name: string;
email: string;
}
const query = useBlockingQuery<User>({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
blockingConfig: {
scope: "user",
reason: "Loading user...",
},
});
// query.data is User | undefined
const mutation = useBlockingMutation<
User, // Response type
Error, // Error type
{ name: string } // Variables type
>({
mutationFn: (variables) => createUser(variables),
blockingConfig: {
scope: "user-form",
reasonOnPending: "Creating user...",
},
});Runtime Stability
Blocker synchronization is designed to stay stable across rerenders.
- Inline
blockingConfigobjects update the existing blocker instead of causing remove/add churn - Cleanup is safe under React
StrictMode - Query and mutation blocker IDs stay unique per hook instance, even when keys match
- blocker management reads store actions directly, avoiding unnecessary rerenders from store subscriptions
React-Action-Guard Concepts
This package integrates TanStack Query with React-Action-Guard's powerful scope-based UI blocking system. Here are the key concepts:
Scope Isolation
Different parts of your UI can block independently via scopes:
import { useIsBlocked } from "@okyrychenko-dev/react-action-guard";
// Query blocks 'users-table' scope
function UserTableLoader() {
useBlockingQuery({
queryKey: ["users"],
queryFn: fetchUsers,
blockingConfig: { scope: "users-table" },
});
}
// Table checks its scope
function UserTable() {
const isBlocked = useIsBlocked("users-table");
// Blocked during query load
}
// Sidebar has different scope - stays interactive
function Sidebar() {
const isBlocked = useIsBlocked("sidebar");
// isBlocked === false ✅
}useIsBlocked & useBlockingInfo
React to blocking state from any component:
import { useIsBlocked, useBlockingInfo } from "@okyrychenko-dev/react-action-guard";
// Anywhere in your app
function StatusBar() {
const blockers = useBlockingInfo("dashboard");
if (blockers.length > 0) {
return <div>{blockers[0].reason}</div>; // "Loading dashboard..."
}
return null;
}
function SaveButton() {
const isBlocked = useIsBlocked("checkout");
return <button disabled={isBlocked}>Proceed</button>;
}Multi-Component Coordination
One query/mutation coordinates many components automatically:
// Component A: Sets blocking
function SaveData() {
const mutation = useBlockingMutation({
mutationFn: saveData,
blockingConfig: { scope: "edit-mode" },
});
}
// Components B, C, D: All react automatically
function FormInputs() {
const isBlocked = useIsBlocked("edit-mode");
// Inputs disabled during save
}
function CancelButton() {
const isBlocked = useIsBlocked("edit-mode");
// Button disabled during save
}
function NavigationLinks() {
const isBlocked = useIsBlocked("edit-mode");
// Navigation blocked during save
}
// No prop drilling! No context! Just scope-based coordination 🎯Priority System
Higher priority blockers override lower ones:
// Mutation default priority: 30 (higher than queries: 10)
const paymentMutation = useBlockingMutation({
mutationFn: processPayment,
blockingConfig: {
scope: "checkout",
priority: 100, // Highest
},
});
const query = useBlockingQuery({
queryKey: ["cart"],
queryFn: fetchCart,
blockingConfig: {
scope: "checkout",
priority: 50, // Lower - won't block if payment is processing
},
});
// Only highest priority blocker's reason is shown
const blockers = useBlockingInfo("checkout");
const topReason = blockers[0]?.reason; // From priority 100Blocker ID Behavior
blockerId (including the value passed to onTimeout) should be treated as an opaque identifier.
- For query/mutation hooks, IDs are unique per hook instance.
- Same
queryKey/mutationKeyin two mounted components now creates two independent blockers. - Unmounting one instance does not remove another instance's blocker.
Use Cases
Loading States
function DataLoader() {
const query = useBlockingQuery({
queryKey: ["data"],
queryFn: fetchData,
blockingConfig: {
scope: "content",
reasonOnLoading: "Loading data...",
onLoading: true,
},
});
// ... rest of component
}Form Submission
import { useBlockingMutation } from "@okyrychenko-dev/react-action-guard-tanstack";
import { useIsBlocked } from "@okyrychenko-dev/react-action-guard";
function UserForm() {
const mutation = useBlockingMutation({
mutationFn: submitForm,
blockingConfig: {
scope: "form",
reasonOnPending: "Submitting form...",
reasonOnError: "Failed to submit",
onError: true,
},
});
const isBlocked = useIsBlocked("form");
const handleSubmit = async (data) => {
await mutation.mutateAsync(data);
};
return (
<form onSubmit={handleSubmit}>
<input disabled={isBlocked} />
<button disabled={isBlocked}>Submit</button>
</form>
);
}Infinite Scrolling
import { useBlockingInfiniteQuery } from "@okyrychenko-dev/react-action-guard-tanstack";
function InfinitePostList() {
const query = useBlockingInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
blockingConfig: {
scope: "post-list",
reasonOnLoading: "Loading posts...",
reasonOnFetching: "Loading more posts...",
onLoading: true,
onFetching: true,
},
});
return (
<div>
{query.data?.pages.map((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{query.hasNextPage && <button onClick={() => query.fetchNextPage()}>Load More</button>}
</div>
);
}Multiple Parallel Queries
import { useBlockingQueries } from "@okyrychenko-dev/react-action-guard-tanstack";
function UserDashboard({ userId }) {
const results = useBlockingQueries(
[
{ queryKey: ["user", userId], queryFn: () => fetchUser(userId) },
{ queryKey: ["posts", userId], queryFn: () => fetchUserPosts(userId) },
{ queryKey: ["stats", userId], queryFn: () => fetchUserStats(userId) },
],
{
scope: "user-dashboard",
reasonOnLoading: "Loading dashboard...",
onLoading: true,
}
);
const [userQuery, postsQuery, statsQuery] = results;
return (
<div>
<h1>{userQuery.data?.name}</h1>
<p>Posts: {postsQuery.data?.length}</p>
<p>Total views: {statsQuery.data?.views}</p>
</div>
);
}Global Loading Overlay
import { useIsBlocked } from "@okyrychenko-dev/react-action-guard";
function App() {
const isGloballyBlocked = useIsBlocked("global");
return (
<div>
{isGloballyBlocked && <LoadingOverlay />}
<YourApp />
</div>
);
}
function SomeComponent() {
const query = useBlockingQuery({
queryKey: ["critical-data"],
queryFn: fetchCriticalData,
blockingConfig: {
scope: "global", // Blocks entire app
reasonOnLoading: "Loading critical data...",
},
});
return <div>Content</div>;
}Multi-Step Process with Priority
function MultiStepWizard() {
const [step, setStep] = useState(1);
// Higher priority for payment step
const paymentMutation = useBlockingMutation({
mutationFn: processPayment,
blockingConfig: {
scope: ["navigation", "form"],
reasonOnPending: "Processing payment...",
priority: 100, // High priority
},
});
// Lower priority for other steps
const saveDraftMutation = useBlockingMutation({
mutationFn: saveDraft,
blockingConfig: {
scope: "navigation",
reasonOnPending: "Saving draft...",
priority: 50, // Lower priority
},
});
return <div>Step {step}</div>;
}Background Refetch Without Blocking
function LiveData() {
const query = useBlockingQuery({
queryKey: ["live-data"],
queryFn: fetchLiveData,
refetchInterval: 5000, // Refetch every 5 seconds
blockingConfig: {
scope: "dashboard",
onLoading: true, // Block initial load
onFetching: false, // Don't block background refetch
reasonOnLoading: "Loading data...",
},
});
return <div>Data: {query.data?.value}</div>;
}Conditional Error Blocking
function CriticalDataLoader() {
const query = useBlockingQuery({
queryKey: ["critical-data"],
queryFn: fetchCriticalData,
blockingConfig: {
scope: "app",
onError: true, // Block UI on error
reasonOnLoading: "Loading critical data...",
reasonOnError: "Critical error - please refresh",
},
});
return <div>Content</div>;
}Development
# Install dependencies
npm install
# Run tests
npm run test
# Run tests with coverage
npm run test:coverage
# Build the package
npm run build
# Type checking
npm run typecheck
# Lint code
npm run lint
# Fix lint errors
npm run lint:fix
# Format code
npm run format
# Watch mode for development
npm run devContributing
Contributions are welcome! Please ensure:
- All tests pass (
npm run test) - Code is properly typed (
npm run typecheck) - Linting passes (
npm run lint) - Code is formatted (
npm run format)
Changelog
See CHANGELOG.md for a detailed list of changes in each version.
License
MIT © Oleksii Kyrychenko
