next.js 페이지 바깥으로 못나가게 하기 (router.events)
문제상황
언틸에서 아티클을 작성하다 실수로 창을 닫거나 뒤로가서 내용을 전부 날려먹었다는 피드백을 종종 들었다. (나도 몇번 있었음)
물론 노션처럼 실시간으로 데이터가 저장될 수 있다면 더더욱 좋겠지만... 개인이 작성하는 블로그에서는 실시간성이 매우 중요한 요소가 아니기도 하고, 서버 비용도 고려해봤을때 간단하게 이탈을 방지해줄 수 있는 팝업을 띄우는 것이 좋겠다고 생각했다.
BeforeUnload Event
우선 윈도우 창이 닫히는 것을 감지하기 위해서는 wep api인 BeforeUnload Event를 사용하면 된다.
문서에 나와있는대로, 작성 페이지에서 대화상자를 열기위해 이벤트 트리거 시점에 preventDefault()
를 호출했다.
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
// ..... 아티클 작성 페이지 컴포넌트 내부
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [router])
// .....
이렇게 하면 아티클 작성페이지에서 예기치 않게 창을 닫으려고 하는 경우를 막아줄 수 있다.
하지만.. 창을 닫는 것 이외에도 client side에서 창을 이동하는 경우에는 해당 이벤트가 트리거 되지 않는다. 이부분을 막기 위해 next router 의 기능을 살펴보면서 route 가 바뀌는것을 방지할 수 있는 방법을 찾아보았다.
방법1. ConfirmLink ❌
첫번째로 시도한 방법은 next에서 제공하는 Link를 감싼 ConfirmLink
라는 컴포넌트를 만들어 route가 실제로 push되기 전에 모달을 띄워주는 방법이였다.
실제로 아래와 같이 구현하였고, 아티클 작성페이지에서 표시되고 있는 헤더에 적용해서 아티클 페이지인 경우 컨펌 모달이 표시되도록 해주었다.
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, FC, MouseEvent, useState } from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog'
type ConfirmLinkProps = Omit<ComponentProps<typeof Link>, 'onClick'> & {
title?: string
description?: string
showConfirm: boolean
}
const ConfirmLink: FC<ConfirmLinkProps> = ({
title = '정말 나가시겠어요?',
description = '저장하지 않은 내용은 모두 사라질 수 있어요. 🥲',
showConfirm,
...linkProps
}) => {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (!(e.target instanceof HTMLAnchorElement) || !showConfirm) {
return
}
e.preventDefault()
setIsOpen(true)
}
const handleConfirm = () => {
setIsOpen(false)
router.push(linkProps.href)
}
return (
<>
<Link onClick={handleClick} {...linkProps} />
<ConfirmDialog
title={title}
open={isOpen}
onConfirm={handleConfirm}
onClose={() => setIsOpen(false)}
onCancel={() => setIsOpen(false)}
>
{description}
</ConfirmDialog>
</>
)
}
export default ConfirmLink
여기까지는 분명 잘 동작했지만... 이 컴포넌트로는 뒤로가기를 막아줄 수 있는 방법이 없었다.
또한 라우트가 생겨날 때마다 해당 컴포넌트를 사용해줘야한다는 컨텍스트를 가지고 있어야하기에 좋은 방법은 아니라고 생각했다.
때문에 다른 방법을 찾아보기로 했다.
방법2. next.js 에서 제공하는 router.events 사용하기 ✅
pages router 기준으로 작성했습니다.
next.js에서는 라우트가 변경되는것을 감지할 수 있도록 이벤트 메서드를 제공하고 있다. (문서)
이 중 routeChangeStart 를 통해 route가 시작되는 시점에 이벤트를 감지하고 에러를 발생시킴으로써 라우팅을 막아주기로 했다.
그렇게 작성된 코드는 아래와 같다.
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
const ArticleForm: FC<Props> = ({ defaultValues }) => {
const router = useRouter()
const [blockedUrl, setBlockedUrl] = useState<string | null>(null)
const syncUrlWithRouter = useCallback(() => {
if (router.asPath !== window.location.pathname) {
window.history.pushState(null, '', router.asPath)
}
}, [router.asPath])
const handleRouteChangeStart = useCallback(
(url: string) => {
if (decodeURIComponent(url).includes(decodeURIComponent(router.asPath))) {
return
}
syncUrlWithRouter()
setBlockedUrl(url)
router.events.emit('routeChangeError')
throw new Error('routeChangeError')
},
[router],
)
const saveArticle = useSaveArticle((articleUrl) => {
router.events.off('routeChangeStart', handleRouteChangeStart)
router.push(articleUrl)
})
useEffect(() => {
router.events.on('routeChangeStart', handleRouteChangeStart)
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart)
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [router])
return (
<>
<FormProvider {...methods}>
<form css={wrapperStyle} onSubmit={handleSubmit}>
{/* .... */}
</form>
</FormProvider>
<ConfirmDialog
title="정말 나가시겠어요?"
open={!!blockedUrl}
onConfirm={() => {
router.events.off('routeChangeStart', handleRouteChangeStart)
blockedUrl && router.replace(blockedUrl)
}}
onClose={() => setBlockedUrl(null)}
onCancel={() => setBlockedUrl(null)}
>
저장하지 않은 내용은 모두 사라질 수 있어요. 🥲
</ConfirmDialog>
</>
)
}
export default ArticleForm
이제 뒤로가기 버튼의 경우에도 대응해줄 수 있게 되었고, 아티클 작성시점에는 모달이 발생되지 않도록 이벤트를 종료해주었다.
에러를 발생시키는 것이기 때문에 dev server에서는 next.js 에러 팝업이 발생하게된다.
실제로 이런 방식이 올바른 접근인지에 대해서는 여러 의견이 존재한다. next.js 이슈에서 진행되고 있는 route cancellation에 대한 논의도 한번 구경해보면 좋을 것 같다!