In Vue Router 5, why should a dashboard route use `component: () => import('./Analytics.vue')` instead of wrapping it in `defineAsyncComponent()`?
answer
- two different lazy mechanisms
- the router waits for a plain loader
- a wrapper looks like a ready component
- Options API guards on the inner page
- a dev warning names the fix
basics
~20 sA plain loader lets the router load the page's code before confirming and run its guards. defineAsyncComponent hands over a ready-looking wrapper: the router confirms at once, misses the page's Options API guards, and warns in development.
solid answer
~50 sVue Router 5 has its own lazy loading: when `component` is a function returning a promise, the router calls it during the navigation, waits for the chunk, runs the loaded page's `beforeRouteEnter`, and only then confirms; a failed chunk cancels the navigation and reaches `router.onError()`. `defineAsyncComponent()` returns a component object, so the router treats it as ready: it confirms immediately, `<RouterView>` renders the async wrapper with its loading state, the URL changes before the code exists, and `beforeRouteEnter`, `beforeRouteUpdate` and `beforeRouteLeave` options on the inner page are never found, because the router reads them from the wrapper. Composition API guards registered inside the page still work once it mounts. Development builds warn when a record uses `defineAsyncComponent()` and say to pass the loader directly. Inside a route component, `defineAsyncComponent` remains the right tool for a heavy widget such as a chart.
code
ts · 12 linesimport { defineAsyncComponent } from 'vue'
export const routes = [
// wrong for a route: confirms before the code loads, hides Options API guards
// { path: '/analytics', component: defineAsyncComponent(() => import('./pages/Analytics.vue')) },
// right: the router loads the page as part of the navigation
{ path: '/analytics', name: 'analytics', component: () => import('./pages/Analytics.vue') },
]
// right place for defineAsyncComponent: a heavy widget inside a page
export const RevenueChart = defineAsyncComponent(() => import('./widgets/RevenueChart.vue'))go deeper
Recall that route components are lazy-loaded with a plain arrow function returning import(), not with defineAsyncComponent.
Explain that the router waits for a plain loader but sees a defineAsyncComponent wrapper as a ready component, so the navigation confirms early.
Show the consequences: missing Options API guards, failures bypassing onError, and a migration path that moves loading and error states to router-level hooks.
Separate the two layers in team guidance: route loaders decide which page's code ships, component-level async loading decides what inside a page can wait.
## Two lazy mechanisms that look alike A dashboard's analytics page is heavy, so it should load on demand. Vue offers two ways to write *load this later*: | | Router loader | `defineAsyncComponent()` | |---|---|---| | Written as | `component: () => import('./Analytics.vue')` | `component: defineAsyncComponent(() => import('./Analytics.vue'))` | | What the router receives | a function returning a promise | a component object (an async wrapper) | | When the navigation confirms | after the chunk has loaded | immediately | | Page's Options API route guards | found and run | never found | | Load failure | navigation fails, `router.onError()` | the wrapper's error handling, after the URL already changed | | Recommended for | route components | components rendered inside a page | The Vue Router documentation is explicit: do not use async components as route components; a route component should just be a function. ## What the router does with a plain loader With `() => import(...)`, loading is **part of the navigation**: 1. the before guards run; 2. the router calls the loader and waits, while the user keeps seeing the current page; 3. it runs the loaded component's `beforeRouteEnter`, if defined; 4. `beforeResolve` guards run with the page code present; 5. the navigation confirms, the URL changes and the page renders complete. If the chunk fails, the navigation fails as a whole: the URL does not change, `router.onError()` handlers receive the error and the `router.push()` promise rejects. ## What changes with `defineAsyncComponent()` `defineAsyncComponent()` returns an ordinary component object, a wrapper that loads the real component when it is rendered. The router cannot tell it from any other component, so: - **the navigation confirms at once**: the URL changes and `<RouterView>` renders the wrapper, which shows its loading state, or nothing, while the chunk downloads; - **Options API route guards disappear**: the router reads `beforeRouteEnter`, `beforeRouteUpdate` and `beforeRouteLeave` from the component in the route record, which is now the wrapper. Guards defined on the inner page are never called; - **failures move out of the router**: a failed chunk is handled by the wrapper's error handling after the navigation has already succeeded, so `router.onError()` and the push promise do not see it. Composition API guards, `onBeforeRouteLeave` and `onBeforeRouteUpdate`, register themselves when the inner page runs `setup`, so they still work, but only after it has loaded. Development builds recognise the async wrapper and warn that the record's component is defined using `defineAsyncComponent()`, with the fix: drop the wrapper and pass the loader directly, because the router handles lazy components itself. ## What the user sees | Moment | Plain loader | `defineAsyncComponent()` wrapper | |---|---|---| | click on *Analytics* | current page stays, navigation pending | URL changes at once | | while the chunk downloads | still the previous page | the wrapper's loading state, or an empty view | | chunk loaded | URL changes, full page renders | page replaces the loading state | | chunk fails | still the previous page; `router.onError()` runs | the wrapper's error state, on the new URL | The left column is what users expect from a link: nothing changes until the destination is ready. The right column shows a new URL with no content behind it, and a failure that leaves the user on a broken page rather than where they were. ## Where `defineAsyncComponent` does belong Inside a route component it is the right tool. The analytics page itself is a route loader; the 400 KB charting widget it shows below the fold can be a `defineAsyncComponent()` so the page renders first and the chart streams in with its own loading and error components. The two layers compose: route-level splitting decides which page's code loads, component-level splitting decides what inside the page can wait. ## Migrating existing records Codebases that went through a Vue 2 upgrade sometimes carry `defineAsyncComponent` in route tables. The fix is mechanical: - replace `defineAsyncComponent(() => import('./X.vue'))` with `() => import('./X.vue')`; - move any `loadingComponent` behaviour to the page shell or a global progress indicator driven by `beforeEach`/`afterEach`; - move `errorComponent` behaviour to `router.onError()`, which now sees chunk failures. ## Common mistakes - Assuming both forms behave the same because both split the bundle. - Relying on `beforeRouteEnter` in a page that is wrapped in `defineAsyncComponent()`. - Expecting `router.onError()` to catch chunk failures of wrapped pages. - Removing `defineAsyncComponent` from widgets inside pages, where it is correct.
- The analytics page defines `beforeRouteLeave` in its options and is wrapped in `defineAsyncComponent`. Does the leave guard run?No. The router looks for `beforeRouteLeave` on the component stored in the route record, which is the async wrapper, and the wrapper has no such option. Switching to a plain loader makes the router find the page's own options; alternatively `onBeforeRouteLeave` in the page's `setup` registers regardless of the wrapper.
- How do you show progress while a lazy route loads, now that the page cannot show its own loading state?Drive a global indicator from the router: start it in a `beforeEach` hook and stop it in `afterEach`, which runs after the navigation settles, and in `router.onError()` for failures. The previous page stays on screen during the load, so a thin progress bar is usually enough.
saying these in an interview costs you the question
- defineAsyncComponent is required for the router to split a page
- Both forms confirm the navigation only after the code has loaded
- router.onError catches chunk failures of pages wrapped in defineAsyncComponent
- Options API route guards on a wrapped page still run
- defineAsyncComponent should never be used anywhere in a router app