├── .eslintignore ├── .npmrc ├── src ├── index.ts ├── util.ts ├── context.ts ├── events.ts ├── useRouter.ts ├── useRouteChangeEvents.ts └── provider.tsx ├── .editorconfig ├── .npmignore ├── .gitignore ├── prettier.config.js ├── .eslintrc.js ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | save-exact=true -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './provider' 2 | export * from './useRouteChangeEvents' 3 | export * from './useRouter' 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,jsx,ts,tsx,json,css}] 4 | indent_size = 2 5 | charset = utf-8 6 | indent_style = space 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .eslintrc.js 3 | .eslintignore 4 | prettier.config.js 5 | src 6 | tsconfig.json 7 | dist/tsconfig.tsbuildinfo 8 | .vscode -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | npm-debug.log* 3 | *.pid 4 | *.seed 5 | *.pid.lock 6 | build/Release 7 | node_modules 8 | .npm 9 | build 10 | dist 11 | .vscode -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const isServer = () => typeof window === 'undefined' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-function 4 | export const noop = () => {} 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | printWidth: 100, 5 | tabWidth: 2, 6 | useTabs: false, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'unused-imports'], 5 | env: { 6 | browser: true, 7 | amd: true, 8 | node: true, 9 | es6: true, 10 | }, 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:prettier/recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | ], 16 | rules: { 17 | 'prettier/prettier': 'error', 18 | 'react/react-in-jsx-scope': 'off', 19 | 'react/prop-types': 0, 20 | 'no-unused-vars': 0, 21 | 'react/no-unescaped-entities': 0, 22 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', 23 | 'no-empty-pattern': 'off', 24 | 'unused-imports/no-unused-imports': 'error', 25 | 'jsx-a11y/alt-text': 0, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useContext } from 'react' 4 | import { noop } from './util' 5 | 6 | let requests: string[] = [] 7 | 8 | interface FreezeRequestsContextValue { 9 | freezeRequests: string[] 10 | setFreezeRequests: React.Dispatch> 11 | } 12 | 13 | export const FreezeRequestsContext = React.createContext({ 14 | freezeRequests: [], 15 | setFreezeRequests: noop, 16 | }) 17 | 18 | export const useFreezeRequestsContext = () => { 19 | const { freezeRequests, setFreezeRequests } = useContext(FreezeRequestsContext) 20 | 21 | return { 22 | freezeRequests, 23 | request: (sourceId: string) => { 24 | requests = [...requests, sourceId] 25 | setFreezeRequests(requests) 26 | }, 27 | revoke: (sourceId: string) => { 28 | requests = requests.filter((x) => x !== sourceId) 29 | setFreezeRequests(requests) 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": false, 21 | "jsx": "react-jsx", 22 | "downlevelIteration": true, 23 | "incremental": true, 24 | "baseUrl": "./", 25 | "verbatimModuleSyntax": true, 26 | "experimentalDecorators": true, 27 | "useDefineForClassFields": true, 28 | "declaration": true, 29 | "outDir": "dist" 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 run4w4y 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-router-events", 3 | "version": "0.0.5", 4 | "description": "A router events alternative for Next.js 13+ with app directory with the ability to prevent user navigation.", 5 | "author": "run4w4y ", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "lint": "eslint --ignore-path .gitignore .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "release": "standard-version --no-verify", 12 | "build": "rm -rf dist && tsc" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/run4w4y/nextjs-router-events" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/run4w4y/nextjs-router-events/issues" 20 | }, 21 | "keywords": [ 22 | "next", 23 | "nextjs", 24 | "router events", 25 | "navigation" 26 | ], 27 | "peerDependencies": { 28 | "next": "^13 || ^14 || ^15", 29 | "react": "^18 || ^19", 30 | "react-dom": "^18 || ^19" 31 | }, 32 | "devDependencies": { 33 | "@types/react": "18.2.20", 34 | "@typescript-eslint/eslint-plugin": "5.62.0", 35 | "eslint": "^8.36.0", 36 | "eslint-config-prettier": "^8.3.0", 37 | "eslint-plugin-prettier": "^3.3.1", 38 | "eslint-plugin-unused-imports": "^2.0.0", 39 | "lint-staged": "^13.2.2", 40 | "prettier": "^2.8.8", 41 | "standard-version": "^9.5.0", 42 | "typescript": "^5.0.4" 43 | }, 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { isServer } from './util' 2 | 3 | export type HistoryURL = string | URL | null | undefined 4 | 5 | export type RouteChangeStartEvent = CustomEvent<{ targetUrl: string }> 6 | export type RouteChangeEndEvent = CustomEvent<{ targetUrl: HistoryURL }> 7 | export type ForceAnchorClickEvent = MouseEvent & { isForceAnchorClickEvent: true } 8 | 9 | declare global { 10 | interface WindowEventMap { 11 | beforeRouteChangeEvent: RouteChangeStartEvent 12 | routeChangeConfirmationEvent: RouteChangeStartEvent 13 | routeChangeStartEvent: RouteChangeStartEvent 14 | routeChangeEndEvent: RouteChangeEndEvent 15 | } 16 | } 17 | 18 | export const triggerRouteChangeStartEvent = (targetUrl: string): void => { 19 | const ev = new CustomEvent('routeChangeStartEvent', { detail: { targetUrl } }) 20 | if (!isServer()) window.dispatchEvent(ev) 21 | } 22 | 23 | export const triggerRouteChangeEndEvent = (targetUrl: HistoryURL): void => { 24 | const ev = new CustomEvent('routeChangeEndEvent', { detail: { targetUrl } }) 25 | if (!isServer()) window.dispatchEvent(ev) 26 | } 27 | 28 | export const triggerBeforeRouteChangeEvent = (targetUrl: string): void => { 29 | const ev = new CustomEvent('beforeRouteChangeEvent', { detail: { targetUrl } }) 30 | if (!isServer()) window.dispatchEvent(ev) 31 | } 32 | 33 | export const triggerRouteChangeConfirmationEvent = (targetUrl: string): void => { 34 | const ev = new CustomEvent('routeChangeConfirmationEvent', { detail: { targetUrl } }) 35 | if (!isServer()) window.dispatchEvent(ev) 36 | } 37 | -------------------------------------------------------------------------------- /src/useRouter.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useRef, useState } from 'react' 4 | import { useRouter as usePrimitiveRouter } from 'next/navigation' 5 | import { triggerBeforeRouteChangeEvent, triggerRouteChangeStartEvent } from './events' 6 | import { useFreezeRequestsContext } from './context' 7 | 8 | interface NavigateOptions { 9 | scroll?: boolean 10 | } 11 | 12 | type AppRouterInstance = ReturnType 13 | 14 | const createRouterProxy = (router: AppRouterInstance, isFrozen: boolean, signal?: AbortSignal) => 15 | new Proxy(router, { 16 | get: (target, prop, receiver) => { 17 | if (prop === 'push') { 18 | return (href: string, options?: NavigateOptions) => { 19 | const resolvePush = () => { 20 | triggerRouteChangeStartEvent(href) 21 | Reflect.apply(target.push, this, [href, options]) 22 | } 23 | 24 | if (isFrozen) { 25 | window.addEventListener( 26 | 'routeChangeConfirmationEvent', 27 | (ev) => { 28 | if (ev.detail.targetUrl === href) resolvePush() 29 | }, 30 | { signal } 31 | ) 32 | 33 | triggerBeforeRouteChangeEvent(href) // NOTE: may wanna use a timeout here 34 | 35 | return 36 | } 37 | resolvePush() 38 | } 39 | } 40 | 41 | return Reflect.get(target, prop, receiver) 42 | }, 43 | }) 44 | 45 | export const useRouter = (): AppRouterInstance => { 46 | const router = usePrimitiveRouter() 47 | const { freezeRequests } = useFreezeRequestsContext() 48 | const abortControllerRef = useRef(new AbortController()) 49 | const [routerProxy, setRouterProxy] = useState( 50 | createRouterProxy(router, freezeRequests.length !== 0, abortControllerRef.current.signal) 51 | ) 52 | 53 | useEffect(() => { 54 | return () => abortControllerRef.current.abort() 55 | }, []) 56 | 57 | useEffect(() => { 58 | abortControllerRef.current.abort() 59 | const abortController = new AbortController() 60 | 61 | setRouterProxy(createRouterProxy(router, freezeRequests.length !== 0, abortController.signal)) 62 | 63 | return () => abortController.abort() 64 | }, [router, freezeRequests]) 65 | 66 | return routerProxy 67 | } 68 | -------------------------------------------------------------------------------- /src/useRouteChangeEvents.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useId, useState } from 'react' 4 | import { useFreezeRequestsContext } from './context' 5 | import { type HistoryURL, triggerRouteChangeConfirmationEvent } from './events' 6 | 7 | export interface RouteChangeCallbacks { 8 | onBeforeRouteChange?: (target: string) => boolean | void // if `false` prevents a route change until `allowRouteChange` is called 9 | onRouteChangeStart?: (target: string) => void 10 | onRouteChangeComplete?: (target: HistoryURL) => void 11 | } 12 | 13 | export const useRouteChangeEvents = (callbacks: RouteChangeCallbacks) => { 14 | const id = useId() 15 | const { request, revoke } = useFreezeRequestsContext() 16 | const [confrimationTarget, setConfirmationTarget] = useState(null) 17 | 18 | useEffect(() => { 19 | request(id) 20 | 21 | return () => revoke(id) 22 | }, []) 23 | 24 | useEffect(() => { 25 | const abortController = new AbortController() 26 | 27 | window.addEventListener( 28 | 'beforeRouteChangeEvent', 29 | (ev) => { 30 | const { targetUrl } = ev.detail 31 | const shouldProceed = 32 | callbacks.onBeforeRouteChange && callbacks.onBeforeRouteChange(targetUrl) 33 | if (shouldProceed ?? true) { 34 | triggerRouteChangeConfirmationEvent(targetUrl) 35 | } else { 36 | setConfirmationTarget(targetUrl) 37 | } 38 | }, 39 | { signal: abortController.signal } 40 | ) 41 | 42 | window.addEventListener( 43 | 'routeChangeEndEvent', 44 | (ev) => { 45 | callbacks.onRouteChangeComplete && callbacks.onRouteChangeComplete(ev.detail.targetUrl) 46 | }, 47 | { signal: abortController.signal } 48 | ) 49 | 50 | window.addEventListener( 51 | 'routeChangeStartEvent', 52 | (ev) => { 53 | callbacks.onRouteChangeStart && callbacks.onRouteChangeStart(ev.detail.targetUrl) 54 | }, 55 | { signal: abortController.signal } 56 | ) 57 | 58 | return () => abortController.abort() 59 | }, [callbacks]) 60 | 61 | return { 62 | allowRouteChange: () => { 63 | if (!confrimationTarget) { 64 | console.warn('allowRouteChange called for no specified confirmation target') 65 | return 66 | } 67 | triggerRouteChangeConfirmationEvent(confrimationTarget) 68 | }, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FreezeRequestsContext } from './context' 4 | import { 5 | type ForceAnchorClickEvent, 6 | type HistoryURL, 7 | triggerBeforeRouteChangeEvent, 8 | triggerRouteChangeEndEvent, 9 | triggerRouteChangeStartEvent, 10 | } from './events' 11 | import React, { useEffect, useState } from 'react' 12 | 13 | type PushStateInput = [data: unknown, unused: string, url: HistoryURL] 14 | 15 | const createForceClickEvent = (event: MouseEvent): ForceAnchorClickEvent => { 16 | const res = new MouseEvent('click', event) as ForceAnchorClickEvent 17 | res.isForceAnchorClickEvent = true 18 | return res 19 | } 20 | 21 | export const RouteChangesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 22 | const [freezeRequests, setFreezeRequests] = useState([]) 23 | 24 | useEffect(() => { 25 | const abortController = new AbortController() 26 | 27 | const handleAnchorClick = (event: MouseEvent | ForceAnchorClickEvent) => { 28 | const target = event.currentTarget as HTMLAnchorElement 29 | 30 | const isFrozen = freezeRequests.length !== 0 31 | if (isFrozen && !(event as ForceAnchorClickEvent).isForceAnchorClickEvent) { 32 | event.preventDefault() 33 | event.stopPropagation() 34 | 35 | window.addEventListener( 36 | 'routeChangeConfirmationEvent', 37 | (ev) => { 38 | if (ev.detail.targetUrl === target.href) { 39 | const forceClickEvent = createForceClickEvent(event) 40 | target.dispatchEvent(forceClickEvent) // NOTE: may want to use a timeout here 41 | } 42 | }, 43 | { signal: abortController.signal } 44 | ) 45 | 46 | triggerBeforeRouteChangeEvent(target.href) 47 | return 48 | } 49 | 50 | triggerRouteChangeStartEvent(target.href) 51 | } 52 | 53 | const handleAnchors = (anchors: NodeListOf) => { 54 | anchors.forEach((a) => { 55 | a.addEventListener('click', handleAnchorClick, { 56 | signal: abortController.signal, 57 | capture: true, 58 | }) 59 | }) 60 | } 61 | 62 | const handleMutation: MutationCallback = (mutationList) => { 63 | mutationList.forEach((record) => { 64 | if (record.type === 'childList' && record.target instanceof HTMLElement) { 65 | const anchors: NodeListOf = record.target.querySelectorAll('a[href]') 66 | handleAnchors(anchors) 67 | } 68 | }) 69 | } 70 | 71 | const anchors: NodeListOf = document.querySelectorAll('a[href]') 72 | handleAnchors(anchors) 73 | 74 | const mutationObserver = new MutationObserver(handleMutation) 75 | 76 | mutationObserver.observe(document, { childList: true, subtree: true }) 77 | 78 | const pushStateProxy = new Proxy(window.history.pushState, { 79 | apply: (target, thisArg, argArray: PushStateInput) => { 80 | triggerRouteChangeEndEvent(argArray[2]) 81 | return target.apply(thisArg, argArray) 82 | }, 83 | getPrototypeOf: (target) => { 84 | return target 85 | }, 86 | }) 87 | 88 | window.history.pushState = pushStateProxy 89 | 90 | return () => { 91 | mutationObserver.disconnect() 92 | abortController.abort() 93 | window.history.pushState = Object.getPrototypeOf(pushStateProxy) 94 | } 95 | }, [freezeRequests]) 96 | 97 | return ( 98 | 99 | {children} 100 | 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextjs-router-events 2 | A router events alternative for Next.js 13+ with app directory with the ability to prevent user navigation. 3 | 4 | # Disclaimer 5 | Initially I wrote this as a workaround for my project needs, and it worked fine in my case. However, 6 | that does not mean it is production-ready. As such, do NOT use this package in production, unless you absolutely must. In case you do, you do so at your own risk. 7 | 8 | If you've found a bug or have suggestions regarding this package, feel free to open an issue/pull request. 9 | 10 | # Motivation 11 | Before app directory, Next.js provided developers with the ability to not only track route changes, but also to prevent the user from navigating to another page with router events. Unfortunately, I have not found an official solution for either of those. While I have seen some other community provided workarounds for the former use-case, I have not found one for the latter, so here we are. 12 | 13 | I certainly hope we get official support for both of these use-cases and this package becomes redundant, but meanwhile you can use this. 14 | 15 | # Installation 16 | Install the package from npm 17 | ``` 18 | npm install nextjs-router-events 19 | ``` 20 | 21 | # Caveats 22 | What this package does, is basically attach `click` event listeners to all `a` nodes in the DOM, and from there handle the necessary logic for the route change events. As it is, the package will be treating **all** `a` node clicks as events of navigation, whether it is an anchor link, an external link or an internal link. It also does not check what the `a` node `target` attribute is, so route change events will be triggered for `target="_blank"` as well (except `routeChangeEnd`). As such, you should be keeping that in mind when using this package with things like `nprogress`. 23 | 24 | This package also only handles `router.push`, so all other `router` methods such as `back`, `forward`, `refresh` and etc are not covered. 25 | I do plan on covering more `router` methods where it is possible to do so, as well as offering some sort of opt-out for the `a` nodes in the DOM, however at the moment it is what it is. 26 | 27 | # Setup 28 | The package exports `RouteChangesProvider`, use it inside your `layout` like so 29 | ```typescript 30 | // layout.tsx 31 | import React from 'react' 32 | import { RouteChangesProvider } from 'nextjs-router-events' 33 | 34 | const Layout = ({ children }: { children: React.ReactNode }) => { 35 | return ( 36 | 37 | {children} 38 | 39 | ) 40 | } 41 | 42 | export default Layout 43 | ``` 44 | 45 | After that, if you're using `router.push` in your application you probably also want to replace your `useRouter` from `next/navigation` usage with `useRouter` exported by this package 46 | ```typescript 47 | import { useRouter } from 'nextjs-router-events' 48 | ``` 49 | 50 | If you find it tedious to go through your imports, you could probably use `resolve.alias` in your webpack configuration to just alias `next/navigation` to something that re-exports all of its contents, except for the `useRouter` and instead exports the one from this package. 51 | 52 | # API 53 | 54 | Aside from the `useRouter` (which has the exact same API as the one from `next/navigation`) and `RouteChangesProvider` (whose only prop is `children`), the package exports `useRouteChangeEvents` hook. 55 | 56 | ## `useRouteChangeEvents` props 57 | - `onBeforeRouteChange?: (target: string) => boolean | void` - optional, this function will be called every time **before** the navigation takes place. It takes one argument: the target (for example, `href` attribute of the `a` tag the user clicked) and should return either `undefined` or a `boolean`. If the function returned `true` or `undefined`, the navigation proceeds. If the function returned `false`, the navigation is prevented until `allowRouteChange` (read further) is called. 58 | - `onRouteChangeStart?: (target: string) => void` - optional, this function will be called every time **after** the navigation has already started. Similarly to `onBeforeRouteChange`, the function also receives `target` as its argument. 59 | - `onRouteChangeComplete?: (target: HistoryURL) => void` - optional as well, this will be called every time **after** the navigation has ended. This function also receives `target`, but now instead of just `string` it has the type signature of `string | URL | null | undefined`. 60 | 61 | ## `useRouteChangeEvents` return value 62 | It returns an object that only contains the `allowRouteChange: () => void` function mentioned before. You should only use it after preventing a user navigation, in case there wasn't any navigation prevented prior to calling it nothing really will happen, although you're going to receive a warning in your console. 63 | 64 | # Examples 65 | 66 | ## Preventing user from leaving a page with unsaved changes 67 | Define a `useLeaveConfirmation` hook like this 68 | ```typescript 69 | import { useCallback, useState } from "react" 70 | import { useRouteChangeEvents } from "nextjs-router-events" 71 | import useBeforeUnload from './useBeforeUnload' // read further for an explanation 72 | import { 73 | AlertDialog, 74 | AlertDialogCancel, 75 | AlertDialogContent, 76 | AlertDialogDescription, 77 | AlertDialogFooter, 78 | AlertDialogHeader, 79 | AlertDialogTitle, 80 | AlertDialogAction, 81 | } from "@/components/ui/alertDialog" // this is just radix-ui Alert Dialog, replace it with whatever fits your project 82 | 83 | const useLeaveConfirmation = (shouldPreventRouteChange: boolean) => { 84 | const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) 85 | const onBeforeRouteChange = useCallback(() => { 86 | if (shouldPreventRouteChange) { 87 | setShowConfirmationDialog(true) 88 | return false 89 | } 90 | 91 | return true 92 | }, [shouldPreventRouteChange]) 93 | 94 | const { allowRouteChange } = useRouteChangeEvents({ onBeforeRouteChange }) 95 | // this is technically unrelated to this package, but probably still is something you might want to do 96 | useBeforeUnload(shouldPreventRouteChange) 97 | 98 | return { 99 | confirmationDialog: ( 100 | 104 | 105 | 106 | 107 | You have unsaved changes 108 | 109 | 110 | Are you sure you want to leave? 111 | All the unsaved changes will be lost. 112 | 113 | 114 | 115 | 116 | Cancel 117 | 118 | { 119 | allowRouteChange() 120 | }}> 121 | Proceed 122 | 123 | 124 | 125 | 126 | ) 127 | } 128 | } 129 | 130 | export default useLeaveConfirmation 131 | ``` 132 | 133 | Now, you can use this hook in a component like this (this is not actual working code, just an example) 134 | ```typescript 135 | import useLeaveConfirmation from '@/hooks/useLeaveConfirmation' 136 | import { useStore } from '@/store' 137 | 138 | const Component = () => { 139 | const store = useStore() // your hypothetical application state 140 | // below replace `store.isDirty` with whatever logic to determine whether or not your application state has been modified by the user 141 | const { confirmationDialog } = useLeaveConfirmation(store.isDirty()) 142 | 143 | // render the confirmationDialog somewhere 144 | return ( 145 | <> 146 | ... 147 | {confirmationDialog} 148 | ... 149 | 150 | ) 151 | } 152 | ``` 153 | 154 | ### Note 155 | Since you are trying to prevent the user from leaving, you probably also want to cover the cases where user "leaves" using browser-native navigation methods such as back button or page refresh. In case you do, you might as well use `useBeforeUnload` hook within `useLeaveConfirmation`. The hook can be defined like this: 156 | ```typescript 157 | import { useEffect } from "react" 158 | 159 | // NOTE: although there is a message argument, you really should not be relying on it, as most, if not all, modern browsers completely ignore it anyways 160 | const useBeforeUnload = (shouldPreventUnload: boolean, message?: string) => { 161 | useEffect(() => { 162 | const abortController = new AbortController() 163 | 164 | if (shouldPreventUnload) 165 | window.addEventListener('beforeunload', (ev) => { 166 | ev.preventDefault() 167 | 168 | return (ev.returnValue = message ?? '') 169 | }, { capture: true, signal: abortController.signal }) 170 | 171 | return () => abortController.abort() 172 | }, [shouldPreventUnload, message]) 173 | } 174 | 175 | export default useBeforeUnload 176 | ``` 177 | --------------------------------------------------------------------------------