KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

【App Router】Next.jsでページの離脱防止を実装する

こんにちは。フロントエンド開発を担当している Y.O. です。

現在開発中のプロダクトでは、Next.js(App Router) を採用しています。

開発を進める中で、フォームやデータを編集中にページを離脱しようとした際、警告を表示する処理を実装する必要がありました。 いわゆる「入力内容が消えますがよろしいですか?」のようなやつですね。

こんなの

もし入力中の内容が、ユーザーの意図しない操作でクリアされてしまったら、「もういいや」と、そのままタブを閉じられてしまうかもしれません。

この離脱防止の処理、Pages Router 時代にはrouter.eventsを使って比較的簡単に実装できたのですが、App Router ではこの機能が廃止されました。(議論についてはこちら)

最終的には実装できたのですが、ハマったこと等も含めてこの記事で共有したいと思います。

離脱ブロック処理の実装

結論から言うと、離脱のブロック自体は、あるライブラリを使うことで簡単に実現できました。

そのライブラリがこちらです。↓↓↓ speakerdeck.com 本当に実装者の方には感謝しかありません。 こちらのスライドはApp Routerのルーティング周りの内部的な理解も深まるので、興味があればぜひご一読を。 スライド内でもありますが、ハック的な内容なため、完璧に遷移をブロックできるとは思っていません。 ただ基本的なnext/linkrouter.push()での遷移や、ブラウザバック・フォワード、タブを閉じる等に反応してくれるので、問題ないかなと思っています。

リポジトリはこちら

導入と使い方もとても簡単です。

導入

  1. インストール
npm i next-navigation-guard
  1. ルートレイアウトに配置
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