Routing

Navigation and Route State

navigate and navigateByName, live route state, navigation direction and history entry state, scroll memory.

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.

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:

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.