Routing
Navigation and Route State
navigate and navigateByName, live route state, navigation direction and history entry state, scroll memory.
Navigating in code
To navigate from code, use this.context (the running Application) inside a component, and the application instance or useNavigation() outside one:
To navigate from code, use useNavigation() in a function component, and the application instance outside one:
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
const nav = useNavigation();
const note = notes.create();
nav.navigate(`/notes/${note.id}`);
const note = this.notes.create();
this.context.navigate(`/notes/${note.id}`);
const nav = useNavigation();
const note = notes.create();
nav.navigate(`/notes/${note.id}`);
From the application instance, with navigation options:
await app.navigate("/notes/5");
await app.navigateByName("notes-note", { id: 42 });
const first = app.provider.get(NotesService).list("notes")[0];
await app.navigate(`/notes/${first.id}`, { replace: true });
await app.router.navigate("/archive", { replace: true });
await app.navigate("/notes/5");
await app.navigateByName("notes-note", { id: 42 });
const first = app.provider.get(NotesService).list("notes")[0];
await app.navigate(`/notes/${first.id}`, { replace: true });
await app.router.navigate("/archive", { replace: true });
app.navigate(uri, options?) and app.router.navigate(uri, options?) take the same NavigationOptions type; app.navigate passes the options on to the router.
| Option | Effect |
|---|---|
replace |
Replaces the current history entry instead of adding a new one. |
state |
A value written to the history entry (see Navigation direction and entry state). |
force |
Navigation to the address on screen is normally ignored; with force: true the middleware, the guards and onRouterChanged still run. The page on screen is not recreated. |
scroll |
Scrolling at the end of the navigation: 'top', 'smooth', 'instant' or { top, left, behavior }. When given, it overrides scrollMemory. |
app.navigate returns the result of the navigation: for a completed navigation, the ResolveResult of the route shown (ok: false for an address that was not found and for the error page); for an ignored navigation, { ok: true, skipped: true, uri }; for a cancellation by a guard or onLeave, { ok: false, cancelled: true, reason } (reason: 'guard', 'onLeave' or the text given by onLeave); for a navigation stopped by middleware, { ok: false, uri, cancelled: true, reason: 'middleware' }; if the router was restarted with restartRouter() or shut down with app.dispose() before the navigation finished, { ok: false, uri, cancelled: true, reason: 'restart' | 'dispose' }; if the navigation falls over with an unexpected error, MJX317 is reported and the result is { ok: false, uri, cancelled: true, reason: 'error', error }. app.router.navigate and app.router.navigateByName return the same result.
useNavigation() returns the same object as app.router:
import { useNavigation } from "@motifx/core";
const nav = useNavigation();
await nav.navigate("/archive");
await nav.navigateByName("notes-note", { id: 7 });
nav.params; // current path parameters: { id: '7' }
nav.route; // the current route
nav.uri; // the current URI
nav.fullPath; // the full path
nav.meta; // route meta data: { folder: 'notes' }
nav.chain; // each route in the chain with its own path, parameters filled in: ['/', '/notes', '/7']
import { useNavigation } from "@motifx/core";
const nav = useNavigation();
await nav.navigate("/archive");
await nav.navigateByName("notes-note", { id: 7 });
nav.params; // current path parameters: { id: '7' }
nav.route; // the current route
nav.uri; // the current URI
nav.fullPath; // the full path
nav.meta; // route meta data: { folder: 'notes' }
nav.chain; // each route in the chain with its own path, parameters filled in: ['/', '/notes', '/7']
Live route state
app.router is a single object for the whole application. A router taken into a field, from useApplication().router or from useNavigation() gives the current route on every read. The route fields (params, route, uri, ok, meta, extend, fullPath, aliasOf, chain, direction, state, stack) are reactive: read inside a function, they run again on navigation. A layout that stays in place throughout navigation is updated on every navigation by reading the route fields inside a getter; it is not rebuilt. In the example, two attributes are calculated from the route's meta and params values:
import { Component, RouterView, useNavigation } from "@motifx/core";
export default class MainLayout extends Component {
nav = useNavigation();
override view() {
return (
<div
class="app"
data-layout={() => (this.nav.meta.folder ? "notes" : "page")}
data-pane={() => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail")}
>
<RouterView />
</div>
);
}
}
import { RouterView, useNavigation } from "@motifx/core";
export default function MainLayout() {
const nav = useNavigation();
return (
<div
class="app"
data-layout={() => (nav.meta.folder ? "notes" : "page")}
data-pane={() => (nav.meta.folder && !nav.params.id ? "list" : "detail")}
>
<RouterView />
</div>
);
}
import { motifOptions, RouterView, useNavigation } from "@motifx/core";
const MainLayout = () => motifOptions({
el: "div",
nav: useNavigation(),
onConfigured() {
this.class.add("app");
this.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
},
view() {
return <RouterView />;
},
});
export default MainLayout;
import { Component, ComponentBase, motifComponent, RouterView, useNavigation } from "@motifx/core";
export default class MainLayout extends Component {
nav = useNavigation();
override view() {
return motifComponent("div", (app: ComponentBase) => {
app.class.add("app");
app.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
app.controls.add(motifComponent(RouterView));
});
}
}
import { ComponentBase, motifComponent, RouterView, useNavigation } from "@motifx/core";
export default function MainLayout() {
const nav = useNavigation();
return motifComponent("div", (app: ComponentBase) => {
app.class.add("app");
app.attr.add({
"data-layout": () => (nav.meta.folder ? "notes" : "page"),
"data-pane": () => (nav.meta.folder && !nav.params.id ? "list" : "detail"),
});
app.controls.add(motifComponent(RouterView));
});
}
import { motifComponent, motifOptions, RouterView, useNavigation } from "@motifx/core";
const MainLayout = () => motifOptions({
el: "div",
nav: useNavigation(),
onConfigured() {
this.class.add("app");
this.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
},
view() {
return motifComponent(RouterView);
},
});
export default MainLayout;
import { Component, RouterView, useNavigation } from "@motifx/core";
export default class MainLayout extends Component {
nav = useNavigation();
view() {
return (
<div
class="app"
data-layout={() => (this.nav.meta.folder ? "notes" : "page")}
data-pane={() => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail")}
>
<RouterView />
</div>
);
}
}
import { RouterView, useNavigation } from "@motifx/core";
export default function MainLayout() {
const nav = useNavigation();
return (
<div
class="app"
data-layout={() => (nav.meta.folder ? "notes" : "page")}
data-pane={() => (nav.meta.folder && !nav.params.id ? "list" : "detail")}
>
<RouterView />
</div>
);
}
import { motifOptions, RouterView, useNavigation } from "@motifx/core";
const MainLayout = () => motifOptions({
el: "div",
nav: useNavigation(),
onConfigured() {
this.class.add("app");
this.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
},
view() {
return <RouterView />;
},
});
export default MainLayout;
import { Component, motifComponent, RouterView, useNavigation } from "@motifx/core";
export default class MainLayout extends Component {
nav = useNavigation();
view() {
return motifComponent("div", (app) => {
app.class.add("app");
app.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
app.controls.add(motifComponent(RouterView));
});
}
}
import { motifComponent, RouterView, useNavigation } from "@motifx/core";
export default function MainLayout() {
const nav = useNavigation();
return motifComponent("div", (app) => {
app.class.add("app");
app.attr.add({
"data-layout": () => (nav.meta.folder ? "notes" : "page"),
"data-pane": () => (nav.meta.folder && !nav.params.id ? "list" : "detail"),
});
app.controls.add(motifComponent(RouterView));
});
}
import { motifComponent, motifOptions, RouterView, useNavigation } from "@motifx/core";
const MainLayout = () => motifOptions({
el: "div",
nav: useNavigation(),
onConfigured() {
this.class.add("app");
this.attr.add({
"data-layout": () => (this.nav.meta.folder ? "notes" : "page"),
"data-pane": () => (this.nav.meta.folder && !this.nav.params.id ? "list" : "detail"),
});
},
view() {
return motifComponent(RouterView);
},
});
export default MainLayout;
The attributes become notes / list at /notes, notes / detail at /notes/3, and page / detail at /settings; the layout is not rebuilt, only the attributes are updated.
- Live reading goes through the object.
const { params } = useNavigation()orconst id = nav.params.idtakes the value of that moment and does not change afterwards; for the current value, readnav.params.idevery time. - The route changes after the leaving page has been taken off the screen and before the new page is attached (in stack navigation, before the transition animation);
scopedservices move to the new navigation at the same moment. A page taken into thekeepAlivecache sees its own route inonDeactivated. TheonDisposingandonDisposedhooks of a page being disposed run after the new page has come on screen, and see the route that was navigated to (see The page that leaves). A new page built by the router sees the route being navigated to in its constructor and in its field initial values. - Guards and route hooks (
useGuard,onEntering,onUpdate) run before the route changes; the route being navigated to is in the context object (to,params). In a cancelled navigation the route does not change. keepAlivepages in the cache read the current route even while hidden: when another route is visited, their bindings run again with that route's values. Do route-dependent work (such as loading data) inonActivated.- Before
useRouter()is called, the route fields are empty (params{},uri'',routenull,chain[]) andnavigatethrowsMJX309.
Listening for route changes
For values that show up on screen, reading the reactive fields inside a function is enough; MainLayout above does that. If some side work has to be done after a navigation (the tab title, focus, a measurement), listen to onRouterChanged. app.onRouterChanged(fn) returns a function that removes the subscription. When context.onRouterChanged(fn) is written inside a component (this.context in a class and in Options, the context of the root component in a function component), the subscription is tied to the component's lifetime: it goes away on its own when the component is disposed, with no need to remove it separately. The payload has the form { uri, params, meta, route, ok, initial, redirectedFrom?, direction?, state? }; on the startup navigation initial is true (see Navigation guards).
Navigation direction and entry state — direction, state
The direction of every navigation arrives in the direction field: 'initial' (startup), 'push' (a new history entry; this includes an address typed into the address bar by hand and an entry opened by a plain anchor link), 'replace', 'back', 'forward', or 'traverse' for a history move whose order cannot be known. The direction shows up in guards (to.direction), in the onRouterChanged payload and in app.router.direction. Use it to pick transitions by direction (slide left going forward, right going back).
A value given with navigate(uri, { state }) is written to that history entry, and it comes back when returning to the entry with back/forward as well; it is kept even if the page is reloaded. It suits data that has to travel without being written to the address (which filter the list was reached from, the open tab):
await app.navigate("/notes/7", { state: { from: "archive", query: "meeting" } });
app.router.state; // { from: 'archive', query: 'meeting' } — also when coming back with back/forward
app.useGuard(({ to }, next) => { console.log(to.direction, to.state); next(); });
app.onRouterChanged(({ direction, state }) => console.log(direction, state));
await app.navigate("/notes/7", { state: { from: "archive", query: "meeting" } });
app.router.state; // { from: 'archive', query: 'meeting' } — also when coming back with back/forward
app.useGuard(({ to }, next) => { console.log(to.direction, to.state); next(); });
app.onRouterChanged(({ direction, state }) => console.log(direction, state));
Plain objects are stored as they are (the router only adds its own stamp, and app.router.state removes it); values that are not plain, such as Date and Map, are not touched at all. For an entry without state, app.router.state is undefined.
Scroll memory — scrollMemory
In a single-page application navigation changes the document but not the scroll position, and the browser does not manage that for an SPA on its own. When scrollMemory is turned on, the router keeps the last scroll position of every path and puts the position back when that path is returned to (back/forward, a link, a reload):
app.useRouter({ routes, mode: "history", scrollMemory: true });
app.useRouter({ routes, mode: "history", scrollMemory: true });
The rules:
- A path never seen before starts at the top of the page (turned off with
top: false). - If there is an anchor in the address (
/notes/5#history), the anchor overrides the memory and the page scrolls to the matchingid. If there is a fixed top bar, givescroll-margin-topin CSS. - Scrolling is instant, even if CSS has
scroll-behavior: smooth: returning to a saved position, the top of a new page, an anchor arriving from another page, and back/forward all settle into place without waiting, as in the browser's own page transitions. An anchor link within the same page (/notes/5→/notes/5#history, including a plain<a href="#history">inhistorymode) follows thescroll-behaviorrule in CSS; ifsmoothis given, it slides smoothly. - The navigation-specific
scrolloption overrides the memory:app.router.navigate('/x', { scroll: 'top' })always goes to the top; it scrolls the container ifcontaineris set, and the window otherwise. When leaving the page navigated to, the position is still saved under that page's name. - Positions are kept in memory: they survive a restart with
restartRouter()and are cleared when the page is reloaded. If they should be remembered after a reload too, you do the saving; where to write them (browser storage, a server) is up to you.
For saving, app.router gives three methods: saveScrollPositions(fn) calls the callback with the current positions every time saving is needed (when leaving a page, on pagehide, when the page is hidden, and on app.dispose()) and returns a function that removes the subscription; the subscription survives a restart as well. restoreScrollPositions(positions) adds the given positions to the memory (limit applies); it can be called at startup, before run() too. scrollPositions() returns the current positions, including that of the page on screen, in the form { "/notes": 1200 }. While scrollMemory is off, scrollPositions() returns an empty object, restoreScrollPositions has no effect and the callback is never called.
app.useRouter({ routes, mode: "history", scrollMemory: true });
app.router.restoreScrollPositions(JSON.parse(localStorage.getItem("scroll") ?? "{}"));
app.router.saveScrollPositions((positions) => localStorage.setItem("scroll", JSON.stringify(positions)));
app.useRouter({ routes, mode: "history", scrollMemory: true });
app.router.restoreScrollPositions(JSON.parse(localStorage.getItem("scroll") ?? "{}"));
app.router.saveScrollPositions((positions) => localStorage.setItem("scroll", JSON.stringify(positions)));
Fine-tuning with a settings object:
app.useRouter({
routes,
scrollMemory: {
top: true, // on a path not seen before, go back to the top (default)
anchor: true, // the anchor overrides the saved position (default)
settleMs: 1200, // upper limit for waiting until the content settles
limit: 60, // how many paths to remember
// If one page is reached from two addresses, reduce them to a single key:
key: (path) => path.replace(/^\/my-notes/, "/notes"),
// Remember the scroll of a container instead of the window (a selector, an element or a function that returns one):
container: "main.detail",
},
});
app.useRouter({
routes,
scrollMemory: {
top: true, // on a path not seen before, go back to the top (default)
anchor: true, // the anchor overrides the saved position (default)
settleMs: 1200, // upper limit for waiting until the content settles
limit: 60, // how many paths to remember
// If one page is reached from two addresses, reduce them to a single key:
key: (path) => path.replace(/^\/my-notes/, "/notes"),
// Remember the scroll of a container instead of the window (a selector, an element or a function that returns one):
container: "main.detail",
},
});
If scrolling happens inside a container (like mobile shells with a fixed header and a tab bar), give container. The container is looked up again on every navigation, so it is fine if it is not in the DOM yet on the first load; if it is not found, window scrolling is used.
If content is drawn asynchronously (remote data, markdown, a virtual list, rows chunked with x-chunk), do not worry: restoring is not a one-off, it is retried on every frame until the target is reached or settleMs runs out. If the user steps in meanwhile with the wheel, a touch, a key or a mouse click, the attempt is dropped.
The cost: zero at rest. The scroll event is not listened to; the position is read once per navigation: after the guards have passed, right before the address is applied and the content is changed. At that moment the leaving page is still on screen and its position is real; in a cancelled navigation the position is not read. While it is on, history.scrollRestoration is set to 'manual'; on pagehide / when the page is hidden, the position of the page on screen is also taken into memory and the saving callback is called. While it is off (the default), no listener is attached and history.scrollRestoration is not touched.
Where the anchor comes from depends on the mode. The #… in the address bar is read as an anchor only in history mode. In hash / file (and shell) mode only the route's own anchor (/page#section) is used; the # part of the address is the route, so it does not count as an anchor. In these modes the memory works with the default anchor setting.
Next step
To control and stop navigation, go on to Route Hooks and Guards.