In Vue Router 5, why does `onBeforeRouteLeave` guard an employee edit form's unsaved changes on leaving, but not when switching to another employee?
answer
- leaving a record versus reusing it
- same record, new params
- the update guard's twin
- register at the top of setup
- clear the dirty flag before saving
basics
~20 sonBeforeRouteLeave runs only when the route record rendering the form is left. /employees/7/edit to /employees/8/edit keeps the same record and component instance, so only onBeforeRouteUpdate runs; register the same check in both and return false to stay.
solid answer
~50 sIn Vue Router 5, `onBeforeRouteLeave` and `onBeforeRouteUpdate` attach a guard to the route record of the nearest `<RouterView>` the component is rendered in. Leave guards run for records the navigation is leaving; update guards run for records that stay matched while params, query or hash change. Moving from `/employees/7/edit` to `/employees/8/edit` matches the same record, so the form component is reused and only the update guard fires. So I register one `confirmDiscard` check with both, returning `false` to keep the user; it may return a promise from an async confirm dialog. Both must be called synchronously in `setup`, in a component under a `<RouterView>`, and they are removed on unmount. After a successful save I clear the dirty flag before `router.push`, or my own guard blocks it; tab close and reload need `beforeunload`, which the router does not see.
code
vue · 25 lines<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import { confirmDialog } from '@/ui/confirm'
import { updateEmployee, type EmployeeForm } from '@/api/employees'
const props = defineProps<{ employee: EmployeeForm }>()
const form = ref({ ...props.employee })
const dirty = ref(false)
const router = useRouter()
async function confirmDiscard() {
if (!dirty.value) return
if (!(await confirmDialog('Discard unsaved changes?'))) return false
}
onBeforeRouteLeave(confirmDiscard)
onBeforeRouteUpdate(confirmDiscard)
async function save() {
await updateEmployee(form.value)
dirty.value = false
await router.push({ name: 'employee', params: { id: form.value.id } })
}
</script>go deeper
Recall that onBeforeRouteLeave runs before leaving the page and that returning false keeps the user there.
Explain leaving versus updating a route record, why a param change reuses the component, and why the same check must be registered with onBeforeRouteUpdate.
Show the production details: registration inside setup under a RouterView, clearing the dirty flag before the post-save push, async confirms, and beforeunload for tab close.
Standardise unsaved-changes handling as one shared composable so every edit screen covers leave, update and unload the same way.
## Two guards for one form Vue Router 5 gives a component two Composition API guards, both imported from `vue-router`: | Guard | Runs when | Options API twin | |---|---|---| | `onBeforeRouteLeave(guard)` | the route record the component lives under is being **left** | `beforeRouteLeave` | | `onBeforeRouteUpdate(guard)` | that record stays matched but params, query or hash **change** | `beforeRouteUpdate` | Each one registers the guard on the route record of the nearest enclosing `<RouterView>`, so it works in the page component and in any child component inside it, such as the form itself. The guard receives `to` and `from` and follows the usual return protocol: return nothing to allow, `false` to stay, or a location to redirect. ## Why switching employees slips past the leave guard The HR portal has a route `/employees/:id/edit`. When the user clicks another employee in the sidebar, the navigation goes from `/employees/7/edit` to `/employees/8/edit`. Both locations match **the same route record**; only the `id` param changes. The router therefore treats that record as *updating*, not *leaving*: 1. the leave guards of records that are no longer matched run, and there are none; 2. the update guards of records that stay matched run, and the form's `onBeforeRouteUpdate` is one of them; 3. the component instance is reused, so without an update guard whatever reacts to the new `id`, such as a watcher loading the next employee, replaces the unsaved edits without asking. The fix is to register the same check with both hooks. ## Which guard fires from the edit page | From `/employees/7/edit` to | Record of the form | Guard that runs | |---|---|---| | `/employees/8/edit` | stays matched, param changes | `onBeforeRouteUpdate` | | `/employees/7/edit?tab=salary` | stays matched, query changes | `onBeforeRouteUpdate` | | `/employees` (the list) | left | `onBeforeRouteLeave` | | `/payroll` | left | `onBeforeRouteLeave` | | Back to `/employees/7` (the profile, another record) | left | `onBeforeRouteLeave` | The rule behind the table: the router compares the route **records** matched before and after, never the URLs, so any change that keeps the edit record matched is an update. ## The guard itself ```ts const dirty = ref(false) async function confirmDiscard() { if (!dirty.value) return const ok = await confirmDialog('Discard unsaved changes?') if (!ok) return false } onBeforeRouteLeave(confirmDiscard) onBeforeRouteUpdate(confirmDiscard) ``` - An `async` guard is fine: the navigation stays pending until the dialog answers. - Returning `false` keeps the user on the form. If they pressed Back, the router restores the edit page's URL. - Returning nothing lets the navigation proceed. ## Registration rules The composables look up the current route record through injection, so where you call them matters: - call them **synchronously at the top of `setup`**, before any `await`; called outside setup they register nothing, and development builds warn; - call them in a component rendered **inside a `<RouterView>`**. In `App.vue`, which sits above every `<RouterView>`, there is no route record to attach to, and a development warning says so; - they are removed automatically when the component unmounts. A component cached by `KeepAlive` drops them on deactivation and registers them again on activation. For the Options API versions, the router also skips the `beforeRouteLeave` and `beforeRouteUpdate` options of route components that are not mounted. ## Saving without tripping your own guard After a successful save the form usually navigates to the employee's profile. If `dirty` is still `true`, the leave guard asks the user whether to discard changes that were just saved. Reset the flag first: ```ts async function save() { await api.updateEmployee(form) dirty.value = false await router.push({ name: 'employee', params: { id: form.id } }) } ``` ## What the router cannot see Route guards cover navigations the router performs. Closing the tab, reloading, or typing a URL that loads a new document never passes through them. For those, add a `beforeunload` listener while the form is dirty; the browser then shows its own generic prompt. ## Common mistakes - Registering only `onBeforeRouteLeave` and losing edits on param changes. - Calling the composables after an `await` or from `App.vue`. - Forgetting to clear the dirty flag before the post-save redirect. - Expecting route guards to catch tab close.
- The form component sits inside a tab component inside the edit page. Does `onBeforeRouteLeave` still work there?Yes. The composable injects the route record from the nearest enclosing `<RouterView>`, so any component rendered under the edit page's view registers on that record. It must still be called synchronously in that component's `setup`, and the guard goes away when that component unmounts.
- How would you write the same protection with the Options API?Add `beforeRouteLeave(to, from)` and `beforeRouteUpdate(to, from)` options to the route component itself, each returning `false` when the user declines. They have access to `this`, so they can read the dirty flag directly. Unlike the composables, these options only take effect on the component the route record renders.
saying these in an interview costs you the question
- onBeforeRouteLeave fires whenever the URL changes, including param changes
- Calling onBeforeRouteLeave in App.vue protects every page
- Route leave guards also stop the user closing the browser tab
- A leave guard cannot be async, so confirm dialogs must be synchronous
- beforeEnter on the edit route runs when switching to another employee