こんにちは。フロントエンド開発を担当している Y.O. です。
現在開発中のプロダクトでは、Next.js(App Router) を採用しています。
開発を進める中で、フォームやデータを編集中にページを離脱しようとした際、警告を表示する処理を実装する必要がありました。 いわゆる「入力内容が消えますがよろしいですか?」のようなやつですね。
もし入力中の内容が、ユーザーの意図しない操作でクリアされてしまったら、「もういいや」と、そのままタブを閉じられてしまうかもしれません。
この離脱防止の処理、Pages Router 時代にはrouter.eventsを使って比較的簡単に実装できたのですが、App Router ではこの機能が廃止されました。(議論についてはこちら)
最終的には実装できたのですが、ハマったこと等も含めてこの記事で共有したいと思います。
- 離脱ブロック処理の実装
- 思わぬハマり①:遷移を止めた後でもNextTopLoaderが動く
- 思わぬハマり②:Next15.3にアップデートしたらnext/linkでの遷移時にブロックとプログレスバーが動かなくなった
- 参考にさせていただいた記事
- おわりに
離脱ブロック処理の実装
結論から言うと、離脱のブロック自体は、あるライブラリを使うことで簡単に実現できました。
そのライブラリがこちらです。↓↓↓
speakerdeck.com
本当に実装者の方には感謝しかありません。
こちらのスライドはApp Routerのルーティング周りの内部的な理解も深まるので、興味があればぜひご一読を。
スライド内でもありますが、ハック的な内容なため、完璧に遷移をブロックできるとは思っていません。
ただ基本的なnext/link
やrouter.push()
での遷移や、ブラウザバック・フォワード、タブを閉じる等に反応してくれるので、問題ないかなと思っています。
リポジトリはこちら
導入と使い方もとても簡単です。
導入
- インストール
npm i next-navigation-guard
- ルートレイアウトに配置
import { NavigationGuardProvider } from 'next-navigation-guard'; const RootLayout = ({ children, }: { children: ReactNode; }) => { return ( <html lang="ja"> : <body> <NavigationGuardProvider> {children} </NavigationGuardProvider> </body> </html> ); }; export default RootLayout;
使い方
useNavigationGuard
hooksを使い、enabled
が true のときにルート遷移前に confirm()
を呼び出します。
'use client' import { useNavigationGuard } from 'next-navigation-guard' import { useState } from 'react' const confirmMessage = '入力内容が保存されていません。本当に移動しますか?' export const FormInput = () => { const [input, setInput] = useState('') const [dirty, setDirty] = useState(false) useNavigationGuard({ enabled: dirty, confirm: () => window.confirm(confirmMessage), }) const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setInput(e.target.value) if (!dirty) setDirty(true) } return ( <input type="text" value={input} onChange={handleChange} placeholder="ここに入力..." /> ) }
これで実際にinputコンポーネントに入力し、ブラウザバックやrouter.push
等で画面を遷移しようとするとアラートが出るようになりました。
思わぬハマり①:遷移を止めた後でもNextTopLoader
が動く
さて、NextNavigationGuard
を導入して無事に警告が出るようになった…のですが、思わぬところで引っかかってしまいました。
本プロジェクトでは、画面遷移時にユーザーへ「遷移中であること」を明示したいという理由から、ページの上部にプログレスバーを出すためNextTopLoader というライブラリを導入していました。
ところがフォーム入力中にnext/link
のリンクをクリック→離脱警告のアラートでキャンセルをクリックしても、プログレスバーが一緒に表示されてしまうのです。
キャンセルしても画面遷移はしないものの、伸びっぱなしです。
なんで?と思ってライブラリの中身を確認したところ、
NextTopLoader
は内部的に、<a>
タグが押されたことを検知すると、さらに内部でnprogress
というライブラリを走らせていることがわかりました。参照
<a>
タグ、つまりここではnext/link
がクリックされた時点で、ほぼ問答無用で プログレスバー が起動してしまうという仕様でした。
なのでキャンセルしても<a>
タグを押したという事実は変わらないので、プログレスバーが走るということでした。
対応策と制限事項
この挙動をNextTopLoader
単体の構成で制御するのは難しそうだったため、NextNavigationGuard
と同じように AppRouterContext
に一部差し込み、必要な場面で明示的に nprogress
を発火させる方針をとりました。
差し込む際のイメージとしては下記のとおりです。
まず、useRouter
をインターセプトするフックスを作成します。(これは使いやすさを考えてフックスにしていますが、後述のTopLoaderProvider
の中に処理を入れてもいいと思います)
import { AppRouterContext, type AppRouterInstance, } from 'next/dist/shared/lib/app-router-context.shared-runtime'; import { usePathname } from 'next/navigation'; import { useContext, useEffect, useMemo } from 'react'; type Props = { beforeNavigation: () => void; afterNavigation: () => void; }; export const useInterceptedRouter = ({ beforeNavigation, afterNavigation, }: Props) => { const origRouter = useContext(AppRouterContext); const pathname = usePathname(); useEffect(() => { afterNavigation(); }, [pathname]); return useMemo((): AppRouterInstance | null => { if (!origRouter) return null; return { ...origRouter, push: (href, ...args) => { beforeNavigation(); origRouter.push(href, ...args); }, replace: (href, ...args) => { beforeNavigation(); origRouter.replace(href, ...args); }, refresh: (...args) => { beforeNavigation(); origRouter.refresh(...args); }, }; }, [origRouter, beforeNavigation]); };
上記のフックスを使ってプログレスバーを表示するプロバイダを作成します。
'use client'; import { useInterceptedRouter } from '@/hooks/use-intercept-router'; import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'; import nProgress from 'nprogress'; import { type ReactNode, useEffect } from 'react'; import 'nprogress/nprogress.css'; export function TopLoaderProvider({ children }: { children: ReactNode }) { const interceptedRouter = useInterceptedRouter({ beforeNavigation: () => nProgress.start(), afterNavigation: () => nProgress.done(), }); useEffect(() => { nProgress.configure({ showSpinner: true, trickle: true, trickleSpeed: 200, minimum: 0.08, easing: 'ease', speed: 200, }); // この辺の処理はNextTopLoaderの処理からほぼそのまま持ってきています ((history: History) => { const pushState = history.pushState; history.pushState = (...args) => { nProgress.done(); return pushState.apply(history, args); }; })((window as Window).history); ((history: History) => { const replaceState = history.replaceState; history.replaceState = (...args) => { nProgress.done(); return replaceState.apply(history, args); }; })((window as Window).history); const doneProgress = (): void => { nProgress.done(); }; window.addEventListener('popstate', doneProgress); window.addEventListener('pagehide', doneProgress); return () => { window.removeEventListener('pagehide', doneProgress); window.removeEventListener('popstate', doneProgress); }; }, []); return ( <AppRouterContext.Provider value={interceptedRouter}> {children} </AppRouterContext.Provider> ); }
上記プロバイダをルートのlayout.tsx
に差し込みます。
import type { ReactNode } from 'react'; import '../styles/globals.css'; import { TopLoaderProvider } from '@/provider/top-loader-provider'; import { NavigationGuardProvider } from 'next-navigation-guard'; const RootLayout = ({ children, }: { children: ReactNode; }) => { return ( <html lang="ja"> <body> <TopLoaderProvider> <NavigationGuardProvider>{children}</NavigationGuardProvider> </TopLoaderProvider> </body> </html> ); }; export default RootLayout;
これでキャンセルを押してもプログレスバーは動かないようになりました。
ただしこの対応にはひとつだけデメリットがあります。
NextTopLoader
から<a>
タグをクリックしたときの挙動をそのまま取り除いた形なので、<a>
タグで直接リンクを実装されてしまうと、プログレスバーが起動しません。
この点については、
「App Router 使ってるなら素直に next/link 使ってくれ!」
という前提で、一旦保留することにしました。
こちらの対応で、画面遷移をブロックし実際に遷移したらプログレスバーが走る、という処理を実現することができました。
思わぬハマり②:Next15.3にアップデートしたらnext/link
での遷移時にブロックとプログレスバーが動かなくなった
上記の形で安定して動いていたのですが、先日Next.jsの15.3がリリースされたので早速アップデートすると、next/link
をクリックした際に、遷移中のブロックもプログレスバーも全く出なくなりました。
新しく追加されたonNavigate APIの影響だろうとは思うのですが、useRouter
のインターセプトに引っかからなくなり、結果的に動かなくなりました。。。(これについては本当に分かりません、有識者の方いればお教えください)
依然としてブラウザバックやページのリロード、閉じる際のブロックには対応できていますが、
仕方がないのでnext/link
でのナビゲーションについては、前述のonNavigate
APIを使用しカスタムしたリンクコンポーネントを作成して対応することにしました。
まずは下記のブロック用のプロバイダを作成します。
'use client'; import { type ReactNode, createContext, useContext, useState } from 'react'; type NavigationBlockerContextType = { isBlocked: boolean; setIsBlocked: (isBlocked: boolean) => void; }; export const NavigationBlockerContext = createContext< NavigationBlockerContextType | undefined >(undefined); export const NavigationBlockerProvider = ({ children, }: { children: ReactNode; }) => { const [isBlocked, setIsBlocked] = useState(false); return ( <NavigationBlockerContext.Provider value={{ isBlocked, setIsBlocked }}> {children} </NavigationBlockerContext.Provider> ); }; export const useNavigationBlocker = () => useContext(NavigationBlockerContext);
下記のカスタムリンクコンポーネントでプログレスバーと遷移のブロックをまとめて実施します。
新期で追加されたuseLinkStatus
を使用します。
'use client'; import Link, { useLinkStatus } from 'next/link'; import nProgress from 'nprogress'; import { type ComponentProps, type ReactNode, useEffect } from 'react'; import 'nprogress/nprogress.css'; import { useNavigationBlocker } from '@/provider/navigation-blocker-provider'; type CustomLinkProps = ComponentProps<typeof Link> & { children: ReactNode; }; nProgress.configure({ showSpinner: true, trickle: true, trickleSpeed: 200, minimum: 0.08, easing: 'ease', speed: 200, }); const TopLoader = () => { const { pending } = useLinkStatus(); useEffect(() => { if (pending) nProgress.start(); else nProgress.done(); }, [pending]); return null; }; export const CustomLink = ({ children, ...props }: CustomLinkProps) => { const nav = useNavigationBlocker(); return ( <Link onNavigate={(e) => { if ( nav?.isBlocked && !window.confirm('編集中のデータがあります。破棄してよろしいですか?') ) e.preventDefault(); else nav?.setIsBlocked(false); }} {...props} > <TopLoader /> {children} </Link> ); };
プロジェクト内で使用しているnext/link
を上記のカスタムリンクコンポーネントと入れ替えます。
あとは、ルートのlayout.tsx
で以下のような順番でプロバイダを使用します。
import type { ReactNode } from 'react'; import { NavigationBlockerProvider } from '@/provider/navigation-blocker-provider'; import { TopLoaderProvider } from '@/provider/top-loader-provider'; import { NavigationGuardProvider } from 'next-navigation-guard'; const RootLayout = ({ children, }: { children: ReactNode; }) => { return ( <html lang="ja"> <body> <TopLoaderProvider> <NavigationGuardProvider> <NavigationBlockerProvider>{children}</NavigationBlockerProvider> </NavigationGuardProvider> </TopLoaderProvider> </body> </html> ); }; export default RootLayout;
このプロバイダー群は別のコンポーネントとしてまとめてもいいかもですね。
これで無事にNext.js 15.3にアップデートしても問題なく動作するようになりました。 現状では、上記の省エネな実装でいったん落ち着いています。
同じように App Router
+ 離脱防止 + 遷移時のプログレスバー
の組み合わせで悩んでいる方の参考になれば幸いです。
参考にさせていただいた記事
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp