🚫

[React Router] ページ離脱時のアラートを表示する機能をラクに導入できるようにカスタムフック化する🔧

に公開

はじめに

ページ離脱防止アラート

何らかの入力フォームでの作業中にタブを閉じてしまったり、ページ離脱をしようとした際に、未保存の内容がありそれが失われてしまうことを警告し防止するためのアラート。「たくさんの項目の入力を行ったのに、誤操作でページを閉じてしまい全部最初からやり直しになってしまった...」というようなケースを防止するために大切な機能ですが、警告アラートの見た目をサービスに合わせたものにしたいだとか、任意の画面でこの機能を導入するための実装の再利用性だとか、状態管理はどうするかだとか、その辺を考慮するとどう実装しようかなとちょっと悩む機能のひとつだと個人的に思っています。

そこで、アラート機能が必要な画面で安心して便利に使えるようなものを目指して、React Routerの機能を活用したカスタムフックの実装をしてみたのでその紹介をしたいと思います。

方針

React Routerではこの機能に関してuseBeforeUnload、useBlockerの2つのhooksが提供されています。本記事ではこれらを活用して、

  • ページリロードやタブ閉じ、外部サイトへの離脱
  • useNavigateや<Link to={...} />などのような、React Routerのナビゲーションによる離脱

の2種類の離脱イベントについてのアラート表示対応実装をします。前者はブラウザの標準機能によってアラートの表示がされるため、UIの見た目やメッセージの内容のカスタマイズがこちら側でできません。後者はReact Routerの機能で検知するイベントなのもあり、UIやメッセージをこちらで実装/設定できます。

[実装] useBeforeUnloadを用いた離脱防止

ページリロードやタブ閉じ、外部サイトへの離脱

こちらの対応ですが、useBeforeUnloadを使わない場合概ね以下のような実装になります。

const [shouldGuard] = useState(false)

useEffect(() => {
  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
    if (!shouldGuard) return
    e.preventDefault()
  }

  window.addEventListener('beforeunload', handleBeforeUnload)
  return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [shouldGuard])

useBeforeUnloadを使うと、ハンドリング処理の部分だけの実装をかいてあとの処理は任せることができます。

const [shouldGuard] = useState(false)

useBeforeUnload(
  useCallback((e: BeforeUnloadEvent) => {
    if (!shouldGuard) {
      return
    }
    e.preventDefault()
  }, [shouldGuard]),
)

さて、これをこのまま利用してしまえば良い気もするのですが、例えばフォームの入力内容を保存するボタンを押してAPIにデータを送信し、問題なく処理が完了した後別の画面へ移動したり、タブを閉じたりしたい場合など「この処理の時だけshouldGuardを無視して離脱したい」場合に少しめんどうなことが起こります。

離脱の直前にshouldGuardをfalseにすればOKかと思いきや、「APIにデータを送信、shouldGuardの状態を更新してページ離脱」という一連の処理が始まった時点でのshouldGuardの状態がtrueだった場合、その処理内でshouldGuardの状態更新を行なっても次のレンダーが起きるまで、そのクロージャが参照しているshouldGuardは古いスナップショット(true)のまま(詳しくは公式ドキュメントを参照)なので、そのままページ離脱アラートが出てしまうのです。

そこで、useRefを使った同期での状態更新をしつつ離脱処理を行うためのユーティリティを添えて機能提供するカスタムフックを以下のように実装しました。

export function useUnloadGuard(shouldGuard: boolean) {
  const _shouldBypassUnloadGuard = useRef(false)

  useBeforeUnload(
    useCallback((e: BeforeUnloadEvent) => {
      if (_shouldBypassUnloadGuard.current) {
        return
      }
      if (!shouldGuard) {
        return
      }
      e.preventDefault()
    }, [shouldGuard]),
  )

  const bypassUnloadGuard = (f: () => void) => {
    _shouldBypassUnloadGuard.current = true
    f()
    _shouldBypassUnloadGuard.current = false
  }

  return {
    bypassUnloadGuard,
  }
}

ページ離脱処理自体は任意の方法で行われるという想定で「コールバック関数をもらってその処理の前後で一時フラグを上げ下げする」という実装にしています。

[実装] useBlockerを用いた離脱防止

useNavigateや<Link to={...} />などのような、React Routerのナビゲーションによる離脱

こちらについては、useBlockerを用いて状態管理、ナビゲーションの制御、カスタムUIへの状態の注入などを行なっていきます。

基本的な実装は以下の通り。

export function useNavigationGuard(shouldGuard: boolean) {
  const blocker = useBlocker(
    useCallback<BlockerFunction>(() => {
      return shouldGuard
    }, [shouldGuard]),
  )

  // 利用するコンポーネントは任意のカスタムアラートコンポーネント
  const AlertDialog = (
    <PageLeaveGuardAlertDialog
      message="ページを離れると、変更が保存されません。"
      isOpen={blocker.state === 'blocked'}
      onConfirm={() => blocker.state === 'blocked' && blocker.proceed()}
      onCancel={() => blocker.state === 'blocked' && blocker.reset()}
    />
  )

  return {
    AlertDialog,
  }
}

useBlockerの引数には遷移をブロックするべきか否かの真偽値を返す関数を渡します。
戻り値は現在遷移がブロックされているかどうかの状態や、その更新のためのアクションなどを含むインターフェースになっているため、これを実際にアラート表示に使いたいコンポーネントに注入してあげてそれをhookの戻り値として返します。利用側では遷移ガードを行うべきかの状態をhookの引数に渡して、提供されるAlertDialogを配置することで機能を実装することができます。

「useBeforeUnloadを用いた離脱防止」と同様、こちらもガードを無視してページ遷移を行いたいパターンに対応する必要があります。今回はその辺を融通する必要のあるケースがuseNavigateを用いたprogrammaticなページ遷移くらいなので、その処理に焦点を当てて対応を組み立てていきます。

useNavigateが提供するNavigateFunctionは第二引数にoptionを受け取ることができ、option.stateに任意のパラメータを設定することで、useBlockerが受けるコールバック関数の引数に流れてくる遷移先情報からそのパラメータを読むことができるようになります。

const blocker = useBlocker(
  useCallback<BlockerFunction>(({ nextLocation }) => {
    // nextLocation.state.xxxxxでnavigateの第二引数で渡したstateが読める
    return shouldGuard
  }, [shouldGuard]),
)

/* ... */

const navigate = useNavigate()
navigate("/", { state: { ... } })

これを利用して、ガードを無視したい遷移の際はstateにbypassBlocker: booleanをもつオブジェクトをこめてあげて、useBlocker側ではnextLocation.state.bypassBlocker === trueならfalseを返す(ガードを行わない)。という実装をすることで、useRefを用いたimperativeな実装を避けることができます。

実際のコードは以下の通り。

export function useNavigationGuard(shouldGuard: boolean) {
  const blocker = useBlocker(
    useCallback<BlockerFunction>(({ nextLocation }) => {
      // nextLocation.stateを読んで、ガードを無視するかどうか判定する(詳細は後述)
      if (extractBypassBlockerPropertyFromState(nextLocation.state)) {
        return false
      }
      return shouldGuard
    }, [shouldGuard]),
  )

  // 利用するコンポーネントは任意のカスタムアラートコンポーネント
  const AlertDialog = (
    <PageLeaveGuardAlertDialog
      message="ページを離れると、変更が保存されません。"
      isOpen={blocker.state === 'blocked'}
      onConfirm={() => blocker.state === 'blocked' && blocker.proceed()}
      onCancel={() => blocker.state === 'blocked' && blocker.reset()}
    />
  )

  // 受け取るoptionの型をOmit<NavigateOptions, 'state'>で、NavigateOptionsからstateプロパティを削除した型にしている。
  // これは、この関数内で添えるstateの値と衝突する可能性を避けるため。
  // 必要に応じて、Omitをせず引数でもらったstateといい感じにマージを行ったりする処理を実装することも検討できる。
  const navigateBypassingGuard = (navigator: NavigateFunction) => (to: To, options?: Omit<NavigateOptions, 'state'>) => {
    return navigator(to, {
      // `{ bypassBlocker: true }`をstateに設定(詳細は後述)
      state: makeLocationStateForBypassingBlocker(),
      ...options,
    })
  }

  return {
    AlertDialog,
    navigateBypassingGuard,
  }
}

利用側に毎回navigate("/", { state: { bypassBlocker: true } })と書かせるのは、useNavigationGuard特有の知識を漏出させていることになり、利便性が落ちる(stateを設定し忘れてガードが働いてしまうというバグを埋める可能性が上がる)ため、navigateBypassingGuardという関数でNavigateFunctionとその引数を受け取って、optionsにstateを添えて実行してあげるようにしています。以下のように利用することができます。

/* ... */
const navigate = useNavigate()
const { navigateBypassingGuard } = useNavigationGuard(shouldGuard)

navigateBypassingGuard(navigate)('/path')

stateの型がanyになっているのは看過できないので...

nextLocation.stateを読むときや、NavigateOptionsのstateに値を設定するとき、このプロパティの型はanyとなっているため型安全ではなく、「何でも設定できる」し「どんな値が流れてくるかもわからない」状態です。また、この文脈でstateという値をどのような形で扱うのか(実際は{ bypassBlocker: boolean }という形で扱っているが)というのが暗黙的です。

ということで、stateに関する型定義や関連関数を以下のように用意します。

// 型チェックにzodを用いているが、自分で型定義と`isLocationState`で型チェックを書いてもOK
import * as z from 'zod'

const LocationStateSchema = z.object({
  bypassBlocker: z.boolean(),
})

// { bypassBlocker: boolean }
type LocationState = z.infer<typeof LocationStateSchema>

// 型チェック。`extractBypassBlockerPropertyFromState`で利用
function isLocationState(state: unknown): state is LocationState {
  return LocationStateSchema.safeParse(state).success
}

// anyになっているstateをunknownとして受け取り、型チェックを行いながら安全に`bypassBlocker`の値を取り出す
export function extractBypassBlockerPropertyFromState(state: unknown): boolean {
  return isLocationState(state) ? state.bypassBlocker : false
}

// 「ガードを無視する場合、stateの値はどのようにしたら良いのか」という知識をこの関数に閉じ込めて、DSLっぽく利用できるように。
export function makeLocationStateForBypassingBlocker(): LocationState {
  return { bypassBlocker: true }
}

こうすることで、後々LocationStateSchemaが変化したりプロパティの追加を行ったりした場合、この辺の実装を調整するだけでOKになるので、保守もしやすい堅牢なコードになるかと思います。

2つの離脱防止実装を統合

ページ離脱防止機能としては、前述した実装2つで1つなので実際に利用側がimportすべきエントリポイントとなるhookを最後に作成します。

import { useNavigationGuard } from './navigation-blocker'
import { useUnloadGuard } from './unload-guard'

export function usePageLeaveGuard(shouldGuard: boolean) {
  const { bypassUnloadGuard } = useUnloadGuard(shouldGuard)
  const { AlertDialog, navigateBypassingGuard } = useNavigationGuard(shouldGuard)

  return {
    AlertDialog,
    bypassUnloadGuard,
    navigateBypassingGuard,
  }
}

こうすることで、ページ離脱アラートを導入したい画面ではusePageLeaveGuardを使ってAlertDialogを配置して、必要に応じてbypassUnloadGuard/navigateBypassingGuardを利用するという感じでシンプルな実装をすることができるようになります。

おわりに

再利用されやすいものほど丁寧に堅牢に、ということで今回は型安全性、再利用性や知識の分散/凝集を意識してhooksを実装してみました。

useNavigationGuardについては、拡張性を考えて具体のコンポーネントに依存しない実装にするということも検討できて以下のようなパターンもあるなと思っています。

  • 単純にuseBlockerの戻り値をラップしたインターフェースを返して、コンポーネントへの紐付けなどは利用側に任せる(headlessな感じ)
  • 引数でアラートUIとしてのインターフェース(useNavigationGuard実装ファイル側で定義)を満たすコンポーネントを受け取るようにして、そこに状態などを注入する(抽象に依存させる)

今回は利用側からみた利便性やシンプルさを優先したいというのと、「ページ離脱アラート機能」って遷移のガードだけではなくUIの表示までやってひとつの機能として完成するよね、という考えのもと、useNavigationGuardには具体のコンポーネントへ依存させています。

また、usePageLeaveGuardは呼ぶだけでなく戻り値のAlertDialogを必ずレンダリング内容に含めてあげる必要があるので、その辺毎回書かなきゃいけないのは微妙かも、という気持ちもありもう少し工夫できるところかなと思っています。

この記事が、自分と同じようにページ離脱防止アラートの実装に悩んでいたり、ベストプラクティスを模索したいという方の参考になれば幸いです。

ispec inc.

Discussion