usePreventNavigationOnUnsavedChanges.ts91 lines · main
| 1 | import { useRouter } from 'next/router' |
| 2 | import { useEffect, useMemo, useState } from 'react' |
| 3 | |
| 4 | import { useStaticEffectEvent } from '../useStaticEffectEvent' |
| 5 | import { BASE_PATH } from '@/lib/constants' |
| 6 | |
| 7 | interface UsePreventNavigationOnUnsavedChangesOptions { |
| 8 | /* |
| 9 | * Boolean indicating whether there are changes that would be lost if users navigate to another |
| 10 | * page or close the browser tab |
| 11 | */ |
| 12 | hasChanges: boolean |
| 13 | } |
| 14 | |
| 15 | interface UsePreventNavigationOnUnsavedChangesReturn { |
| 16 | /* |
| 17 | * Cancel the navigation and keep the changes |
| 18 | */ |
| 19 | handleCancel: () => void |
| 20 | /* |
| 21 | * Confirm the navigation and lose the changes |
| 22 | */ |
| 23 | handleConfirm: () => void |
| 24 | /* |
| 25 | * Boolean indicating whether UI to request users confirmation for the navigation should be |
| 26 | * displayed |
| 27 | */ |
| 28 | shouldConfirm: boolean |
| 29 | } |
| 30 | |
| 31 | /* |
| 32 | * Hook that prevents navigation when users could lose their changes. |
| 33 | * It prevents both NextJS and browser navigation (such as when closing the tab) |
| 34 | */ |
| 35 | export const usePreventNavigationOnUnsavedChanges = ({ |
| 36 | hasChanges, |
| 37 | }: UsePreventNavigationOnUnsavedChangesOptions): UsePreventNavigationOnUnsavedChangesReturn => { |
| 38 | const router = useRouter() |
| 39 | const [navigateUrl, setNavigateUrl] = useState<string>() |
| 40 | const [confirmNavigate, setConfirmNavigate] = useState(false) |
| 41 | |
| 42 | useEffect(() => { |
| 43 | const handleBeforeUnload = (e: BeforeUnloadEvent) => { |
| 44 | if (hasChanges) { |
| 45 | e.preventDefault() |
| 46 | e.returnValue = '' // deprecated, but older browsers still require this |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | const handleBrowseAway = (url: string) => { |
| 51 | if (hasChanges && !confirmNavigate) { |
| 52 | setNavigateUrl(url) |
| 53 | throw 'Route change declined' // Just to prevent the route change |
| 54 | return |
| 55 | } |
| 56 | setNavigateUrl(undefined) |
| 57 | } |
| 58 | window.addEventListener('beforeunload', handleBeforeUnload) |
| 59 | router.events.on('routeChangeStart', handleBrowseAway) |
| 60 | |
| 61 | return () => { |
| 62 | window.removeEventListener('beforeunload', handleBeforeUnload) |
| 63 | router.events.off('routeChangeStart', handleBrowseAway) |
| 64 | } |
| 65 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 66 | }, [confirmNavigate, hasChanges]) |
| 67 | |
| 68 | const handleCancel = useStaticEffectEvent(() => { |
| 69 | setNavigateUrl(undefined) |
| 70 | }) |
| 71 | |
| 72 | const handleConfirm = useStaticEffectEvent(() => { |
| 73 | setConfirmNavigate(true) |
| 74 | let urlToNavigate = navigateUrl ?? '/' |
| 75 | if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) { |
| 76 | urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/' |
| 77 | } |
| 78 | if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}` |
| 79 | setNavigateUrl(undefined) |
| 80 | router.push(urlToNavigate) |
| 81 | }) |
| 82 | |
| 83 | return useMemo( |
| 84 | () => ({ |
| 85 | handleCancel, |
| 86 | handleConfirm, |
| 87 | shouldConfirm: !!navigateUrl, |
| 88 | }), |
| 89 | [navigateUrl, handleCancel, handleConfirm] |
| 90 | ) |
| 91 | } |