@okyrychenko-dev/react-action-guard-router
v1.0.1
Published
Navigation blocking adapters for React Action Guard across React Router, TanStack Router, and Next.js
Maintainers
Readme
@okyrychenko-dev/react-action-guard-router
See the versioned capability matrix for evaluated peers, reproducible checks, evidence kinds, and limitations.
Router integration for React Action Guard - navigation blocking for React Router, Remix, TanStack Router, and Next.js
Start with the core-only coordination example to see one operation affect two independent controls. Add this package when you need its integration. Canonical documentation and local instructions provide the next learning step.
Features
- 🛣️ Multi-Router Support - React Router v6.19+, Remix, TanStack Router, limited Next.js Pages Router interception, and App Router unload-only protection
- 🎯 Scope-Based Blocking - Synchronize navigation blocking with UI blocking scopes
- 🚦 Condition-Based Blocking - Block based on boolean conditions or functions
- 💬 Custom Dialog Support -
useDialogStatehelper for async confirmation dialogs - 🌐 Browser Protection - Prevents tab close/refresh with
beforeunloadevents - 🔄 Sync & Async Handlers - Support for both synchronous and Promise-based confirmations
- 📦 Tree-Shakeable - Import only the router adapter you need
- 🔒 TypeScript-Friendly - Strong editor support and typed router adapters
- 🧹 Auto Cleanup - Automatic listener cleanup on component unmount
Installation
npm install @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guard
# or
yarn add @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guard
# or
pnpm add @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guardThis package requires the following peer dependencies:
- @okyrychenko-dev/react-action-guard - See
peerDependenciesfor compatible Core versions. - React ^18.0.0 || ^19.0.0
- One of:
- react-router-dom ^6.19.0 - For React Router or Remix
- @tanstack/react-router ^1.170.41 - For TanStack Router
- next ^13.4.0 - For limited Pages Router interception and App Router unload-only protection
- Zustand - State management (peer dependency of react-action-guard)
Quick Start
Basic Usage with react-action-guard
The most powerful pattern - synchronize navigation blocking with UI blocking:
import { useActionBlocker } from "@okyrychenko-dev/react-action-guard";
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
function PaymentForm() {
const [isProcessing, setIsProcessing] = useState(false);
// Block UI during payment processing
useActionBlocker(
"payment-processing",
{
scope: "checkout",
reason: "Processing payment...",
},
isProcessing
);
// Block navigation when the same scope is active
useNavigationBlocker({
scope: "checkout",
message: "Payment is processing. Please wait.",
});
// When isProcessing = true, both UI and navigation are blocked
}Simple Condition-Based Blocking
For basic usage without react-action-guard scopes:
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
function EditForm() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useNavigationBlocker({
when: hasUnsavedChanges,
message: "You have unsaved changes. Leave anyway?",
});
}Canonical reference
Read the public reference and integration guide. The expandable material below remains available for existing links.
API Reference
Hooks
useNavigationBlocker(options)
Blocks navigation in router applications based on conditions or scope state.
Parameters:
options: UseNavigationBlockerOptionswhen?: boolean | (() => boolean)- Condition to activate blockingscope?: string | string[]- Scope(s) from react-action-guard to monitormessage?: string- Confirmation dialog messageonConfirm?: (message: string) => boolean | PromiseLike<boolean>- Custom confirmation handler, called once per blocked navigation attemptonBlock?: () => void- Callback when navigation is blockedonAllow?: () => void- Callback when navigation is allowedblockBrowserUnload?: boolean- Block tab close/refresh (default:true)
Returns: { isBlocking: boolean; isIntercepting?: boolean }
isBlocking- Blocking condition is armed for this adapterisIntercepting?: boolean- Active interception state when the router can expose it
Available in:
@okyrychenko-dev/react-action-guard-router/react-router- React Router v6.19+ & Remix@okyrychenko-dev/react-action-guard-router/tanstack-router- TanStack Router@okyrychenko-dev/react-action-guard-router/nextjs- Next.js Pages & App Router
Examples:
// Scope-based blocking
useNavigationBlocker({
scope: "form",
message: "You have unsaved changes",
});
// Condition-based blocking
useNavigationBlocker({
when: hasChanges,
message: "Discard changes?",
onBlock: () => console.log("Navigation blocked"),
onAllow: () => console.log("Navigation allowed"),
});
// Multiple scopes
useNavigationBlocker({
scope: ["form", "validation"],
message: "Form is being validated",
});
// Function condition
useNavigationBlocker({
when: () => formIsDirty() || hasUnsavedData(),
message: "You have unsaved work",
});useDialogState<TMessage = string>()
Helper hook for managing custom confirmation dialogs.
Parameters: None
Returns:
dialogState: DialogState<TMessage> | null- Current dialog statemessage: TMessage- The message passed to confirmisOpen: boolean- Whether dialog is openresolve: (value: boolean) => void- Settle and close this specific dialog
confirm: (message: TMessage) => Promise<boolean>- Show dialog, returns PromiseonConfirm: () => void- Resolve dialog withtrueonCancel: () => void- Resolve dialog withfalse
Each request settles once: confirmation resolves true; cancellation, replacement by
another confirm call, and hook unmount resolve false. Calling dialogState.resolve
also closes that dialog. Repeated calls and resolvers captured from an older dialog
have no effect on a newer dialog.
Effect teardown also cancels and clears the pending dialog when React preserves
hook state, such as when an Activity becomes hidden. Revealing the subtree starts
with no open dialog and allows new confirmation requests.
confirm, onConfirm, and onCancel retain stable references across renders.
The hook-level onConfirm and onCancel always act on the current dialog; use its
dialogState.resolve when a captured callback must belong to a specific dialog.
Example:
import {
useNavigationBlocker,
useDialogState,
} from "@okyrychenko-dev/react-action-guard-router/react-router";
function MyComponent() {
const [hasChanges, setHasChanges] = useState(false);
const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
useNavigationBlocker({
when: hasChanges,
onConfirm: confirm, // Returns Promise<boolean>
});
return (
<>
<form>...</form>
{dialogState && (
<CustomDialog message={dialogState.message} onConfirm={onConfirm} onCancel={onCancel} />
)}
</>
);
}Custom message types:
interface CustomMessage {
title: string;
body: string;
severity: "warning" | "error";
}
const { dialogState, confirm } = useDialogState<CustomMessage>();
useNavigationBlocker({
when: isDirty,
onConfirm: () =>
confirm({
title: "Unsaved Changes",
body: "Your work will be lost",
severity: "warning",
}),
});Render-time conditions
A function passed as when must be a pure condition. Each React render evaluates
it again, including a stable function created with useCallback or one reading a
ref or external state. Changing a ref or external value alone does not trigger a
render or refresh protection; arrange a React rerender or subscription.
useCallback is optional and does not cache the condition's result. A true
condition or a blocked scope enables protection (OR semantics).
This correction affects consumers whose stable predicates previously retained a stale result: protection now follows the current result after rerender. Router capabilities are unchanged; Next App Router protects document unload only and does not intercept same-document navigation.
usePrompt(message, when) (React Router only)
Simple API similar to React Router v5's usePrompt.
Parameters:
message: string- Confirmation messagewhen: boolean- Condition to activate blocking
Example:
import { usePrompt } from "@okyrychenko-dev/react-action-guard-router/react-router";
function MyForm() {
const [hasChanges, setHasChanges] = useState(false);
usePrompt("You have unsaved changes", hasChanges);
}useBeforeUnload(when, message?)
Standalone hook for blocking browser unload events (tab close, refresh).
Parameters:
when: boolean- Condition to activate blockingmessage?: string- Optional custom message (default: "Are you sure you want to leave?")
Example:
import { useBeforeUnload } from "@okyrychenko-dev/react-action-guard-router";
function MyComponent() {
const [hasUnsavedWork, setHasUnsavedWork] = useState(false);
useBeforeUnload(hasUnsavedWork, "You have unsaved work");
}This utility works independently of any router and can be used in any React application.
Router-Specific Notes
React Router (& Remix)
Full support with React Router v6's useBlocker hook.
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
function MyComponent() {
useNavigationBlocker({
when: isDirty,
message: "Discard changes?",
});
}Note: Remix uses React Router v6 internally, so the React Router adapter works seamlessly in Remix applications.
isInterceptingis available here because React Router exposes blocker state directly.
TanStack Router
Uses TanStack Router's native useBlocker for in-app transitions. Requires TanStack Router 1.170.41 or newer; earlier versions are no longer declared supported.
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/tanstack-router";
function MyComponent() {
useNavigationBlocker({
when: isDirty,
message: "Discard changes?",
});
}Sync and async onConfirm are evaluated once per blocked navigation attempt. Active blocking without a message denies silently and emits onBlock. Denial, rejection, and thrown confirmation errors retain the current location. Acceptance permits that transition once and emits onAllow; subsequent transitions remain protected. Superseded confirmations and results received after unmount or authorization option changes cannot authorize navigation. Replacing when or changing the effective scope contents invalidates a pending confirmation even when the computed blocking condition remains true. Equivalent scope arrays preserve the attempt: array identity, order, and duplicates do not affect scope comparison. Changes to observer callbacks onBlock and onAllow do not invalidate a pending confirmation; the attempt retains the callbacks it started with.
scope and when retain their existing OR behavior. blockBrowserUnload controls native TanStack unload protection. Its one-shot bypass suppresses a second browser prompt after accepted document navigation; later unloads remain protected. The adapter does not register a separate shared unload handler. Unload uses a browser-controlled prompt, not asynchronous custom confirmation.
Not-found limitation: In the verified TanStack Router 1.170.41, navigation from an unmatched URL (__notFound__) to a matched route bypasses native useBlocker before this adapter's callback runs, even with when: true or an active scope. onBlock, onConfirm, and onAllow are not called for that transition. A blocker mounted above the not-found UI therefore cannot protect this exit; do not rely on it to guard unsaved work there. Subsequent matched-to-matched navigation remains guarded. This is upstream behavior introduced by the upstream TanStack Router change, covered by real-router tests for both condition sources. The adapter does not compensate for the bypass.
Compatibility tests exercise the public hook at 1.170.41 with real memory history for in-app navigation and browser history in the DOM test environment for unload protection. The unload tests intercept document location assignment and dispatch beforeunload events; they do not establish browser prompt UI behavior. Separate Playwright tests verified same-document back/forward on matched routes in Chrome 154.0.8037.57 on 2026-10-03: custom-dialog cancellation retains the original route and URL, confirmation reaches the intended destination once, and subsequent history attempts remain protected. This evidence does not establish other browsers or cross-document history transitions. Reproduction steps, evaluated versions, and result artifacts are documented in browser-history verification. The verified floor was raised from 1.170.28 because the older locked router-core/history combination skipped blockers for external navigation. The native API is documented in TanStack navigation blocking.
Next.js Pages Router
Limited interception through Next.js router events.
// pages/edit.tsx
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/nextjs";
function EditPage() {
useNavigationBlocker({
when: isDirty,
message: "Discard changes?",
});
}Async onConfirm behavior:
Pages Router does not allow pausing transitions. When you return a Promise from onConfirm, the hook cancels the current navigation and re-attempts it via router.push(url) if confirmed. This may not preserve original transition options (e.g., shallow, scroll, locale).
Only the current confirmation attempt can resume navigation. Cleanup, disabling protection, or changing the protected scope or message invalidates pending answers. Callback rerenders and equivalent scopes preserve the current attempt. onAllow fires once for an accepted attempt; the replay permission is consumed once and cleared when the retry finishes or another URL is attempted.
Selected packed Next 15.5.23 production Pages/App browser evidence and its limitations are recorded in the runtime guide and capability matrix. This does not establish every supported peer or navigation option.
Next.js App Router
Unload-only protection: the hook requests a browser beforeunload prompt, subject to browser policy. It does not intercept same-document Link, push, or back/forward navigation, and does not evaluate onConfirm. App Router does not expose an official navigation-blocking API.
// app/edit/page.tsx
"use client";
import { useState } from "react";
import { useAppRouterBlocker } from "@okyrychenko-dev/react-action-guard-router/nextjs";
export default function EditPage() {
const [isDirty, setIsDirty] = useState(false);
useAppRouterBlocker({
when: isDirty,
message: "You have unsaved changes",
});
return <input onChange={() => setIsDirty(true)} />;
}App Router Limitations:
beforeunloadcan request the browser’s native prompt for document unload (close, refresh, or cross-document navigation), subject to browser policy.- Same-document App Router back/forward is not intercepted.
- ❌
<Link>component navigation is NOT blocked - ❌
router.push()is NOT blocked
Pages Router provides route-event interception with the replay limitations described above.
Adapter Capabilities
| Adapter | isBlocking meaning | isIntercepting | Async onConfirm | Caveats |
| -------------------- | --------------------------- | ---------------- | ----------------- | -------------------------------------------------------- |
| React Router | Blocking condition is armed | Yes | Yes | Best semantic fidelity |
| TanStack Router | Blocking condition is armed | No | Yes | Native useBlocker; memory-history transitions verified |
| Next.js Pages Router | Blocking condition is armed | No | Yes | Re-attempts confirmed navigation with router.push(url) |
| Next.js App Router | Blocking condition is armed | No | No | beforeunload only; no same-document interception |
Use Cases
Form Validation
function SignupForm() {
const [formData, setFormData] = useState({});
const isDirty = Object.keys(formData).length > 0;
useNavigationBlocker({
when: isDirty,
message: "Your signup progress will be lost. Continue?",
});
}Multi-Step Wizard
function MultiStepWizard() {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
// Block UI during submission
useActionBlocker(
"wizard-submit",
{
scope: "wizard",
reason: "Submitting form...",
},
isSubmitting
);
// Block navigation during submission OR if wizard incomplete
useNavigationBlocker({
scope: "wizard",
when: currentStep < 5,
message: `You're on step ${currentStep}/5. Exit wizard?`,
});
}E-Commerce Checkout
import { useBlockingMutation } from "@okyrychenko-dev/react-action-guard-tanstack";
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
function CheckoutPage() {
const paymentMutation = useBlockingMutation({
mutationFn: processPayment,
blockingConfig: { scope: "checkout" },
});
useNavigationBlocker({
scope: "checkout",
message: "Payment is processing. Leave this page?",
});
}UI blocking and navigation confirmation do not cancel the payment request or roll back a transaction. Cancellation, server authorization, and idempotency belong to the application and its backend.
File Upload with Progress
function FileUploader() {
const [uploadProgress, setUploadProgress] = useState(0);
const isUploading = uploadProgress > 0 && uploadProgress < 100;
useNavigationBlocker({
when: isUploading,
message: `Upload ${uploadProgress}% complete. Cancel upload?`,
onBlock: () => pauseUpload(),
});
}TypeScript
TypeScript-friendly API surface:
import type {
// Core types
BaseNavigationBlockerOptions,
NavigationBlockerReturn,
DialogState,
} from "@okyrychenko-dev/react-action-guard-router";
// Router-specific types
import type { UseNavigationBlockerOptions } from "@okyrychenko-dev/react-action-guard-router/react-router";Typed scopes with react-action-guard:
import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
type AppScopes = "form" | "checkout" | "navigation";
const { useActionBlocker } = createTypedHooks<AppScopes>();
function MyComponent() {
useActionBlocker("id", { scope: "form" }); // ✅ Typed in the core package
useNavigationBlocker({
scope: "form", // ✅ Reuses the same scope values cleanly
message: "Leave form?",
});
}Bundle Size
Tree-shakeable by router adapter. Import only what you need:
// Only React Router code is bundled
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";Use the publication report for dated, per-entry-point measurements and the exact evaluated cohort. It records raw and gzip byte counts for distributed files and minimal consumer bundles, including separate React Router, TanStack Router and Next.js entry points.
Reproduce the report with pnpm run package:check from the repository root.
The measurement script
uses the recorded esbuild version, browser ESM output and minification for minimal
consumer bundles. React, React DOM, TanStack Query and router framework imports are
externalized as listed in each report entry. Distributed file sizes are reported
separately from consumer bundles. These profiles measure adapter code under those
exclusions, not the total dependency cost in your application; measure your own
production build when evaluating that cost.
Development
This package lives in the react-action-guard monorepo (pnpm workspaces). From the monorepo root:
# Install dependencies for all packages
pnpm install
# Run this package's scripts with --filter
pnpm --filter @okyrychenko-dev/react-action-guard-router run test:run
pnpm --filter @okyrychenko-dev/react-action-guard-router run build
pnpm --filter @okyrychenko-dev/react-action-guard-router run typecheck
pnpm --filter @okyrychenko-dev/react-action-guard-router run lint
pnpm --filter @okyrychenko-dev/react-action-guard-router run lint:fix
# Or cd into the package and run scripts directly
cd packages/router
pnpm run test:runRelated Packages
- @okyrychenko-dev/react-action-guard - Core UI blocking library
- @okyrychenko-dev/react-action-guard-tanstack - TanStack Query integration
- @okyrychenko-dev/react-action-guard-devtools - DevTools extension
Contributing
Contributions are welcome! Please ensure, before opening a PR:
- Tests pass (
pnpm run test:run) - Code is properly typed (
pnpm run typecheck) - Linting passes (
pnpm run lint) - Code is formatted (
pnpm run format) - If the change affects this package's public behavior, add a changeset:
pnpm changeset(see the Releasing section of the monorepo README)
License
MIT © Oleksii Kyrychenko
