usePreventNavigationOnUnsavedChanges.ts91 lines · main
1import { useRouter } from 'next/router'
2import { useEffect, useMemo, useState } from 'react'
3
4import { useStaticEffectEvent } from '../useStaticEffectEvent'
5import { BASE_PATH } from '@/lib/constants'
6
7interface 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
15interface 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 */
35export 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}