eleva

Router Navigation

Router Plugin Programmatic navigation, reactive state, events, and scroll behavior.

Programmatic Navigation

Basic Navigation

// Navigate to path
await router.navigate("/users/123");
// Result: URL changes to /#/users/123

// Navigate with inline params
await router.navigate("/users/:id", { id: "456" });
// Result: URL changes to /#/users/456
await router.navigate({
  // Target path (can include :param placeholders)
  path: "/users/:id",

  // Replace :param placeholders in path
  params: { id: "123" },

  // Add query string (?key=value)
  query: { tab: "profile", sort: "name" },

  // Replace current history entry instead of pushing new one
  replace: true,

  // State object passed to history.pushState/replaceState
  state: { scrollPosition: 100, fromDashboard: true }
});
// Result: URL changes to /#/users/123?tab=profile&sort=name

Note: The replace, query, and state options are only available when using the object syntax. When using router.navigate(path, params), the second argument is only for path parameter substitution (e.g., replacing :id with a value).

Check Navigation Result

const success = await router.navigate("/protected-page");

if (success) {
  console.log("Navigation succeeded");
} else {
  console.log("Navigation was blocked by a guard or failed");
}

Same-Route Navigation

// Navigating to the current route returns true (no-op)
// Current URL: /#/users/123
const result = await router.navigate("/users/123");
console.log(result); // true (already there, considered successful)

Template-Based Navigation

<!-- Direct hash links work automatically -->
<a href="#/">Home</a>
<a href="#/about">About</a>
<a href="#/users/123">User 123</a>
<a href="#/search?q=eleva">Search</a>

Programmatic from Template

const NavComponent = {
  setup(ctx) {
    const goToUser = async (id) => {
      await ctx.router.navigate(`/users/${id}`);
    };

    const goToSearch = async (query) => {
      await ctx.router.navigate({
        path: "/search",
        query: { q: query }
      });
    };

    return { goToUser, goToSearch };
  },
  template: (ctx) => `
    <nav>
      <button @click="() => goToUser('123')">View User 123</button>
      <button @click="() => goToSearch('eleva')">Search Eleva</button>
    </nav>
  `
};
Method Description Returns
navigate(path) Navigate to path string Promise<boolean>
navigate(path, params) Navigate with param replacement Promise<boolean>
navigate(options) Navigate with full options Promise<boolean>

Reactive State

All route data is exposed as Signals - reactive values that trigger updates when changed.

Available Signals

Signal Type Description
currentRoute Signal<RouteLocation \| null> Complete current route info
previousRoute Signal<RouteLocation \| null> Previous route info
currentParams Signal<Record<string, string>> URL parameters
currentQuery Signal<Record<string, string>> Query string parameters
currentLayout Signal<MountResult \| null> Mounted layout instance
currentView Signal<MountResult \| null> Mounted view/page instance
isReady Signal<boolean> Router ready state

Accessing Values

// Get current value with .value
const route = router.currentRoute.value;
console.log(route.path);    // "/users/123"
console.log(route.params);  // { id: "123" }
console.log(route.query);   // { tab: "settings" }
console.log(route.meta);    // { requiresAuth: true }

// Shorthand access
const params = router.currentParams.value;  // { id: "123" }
const query = router.currentQuery.value;    // { tab: "settings" }
const ready = router.isReady.value;         // true

Watching Changes

// Watch for route changes
const unsubRoute = router.currentRoute.watch((route) => {
  if (route) {
    console.log("Route changed to:", route.path);
    document.title = route.meta.title || "My App";
  }
});

// Watch for param changes (useful for data fetching)
const unsubParams = router.currentParams.watch((params) => {
  if (params.id) {
    fetchUserData(params.id);
  }
});

// Watch for query changes
const unsubQuery = router.currentQuery.watch((query) => {
  if (query.search) {
    performSearch(query.search);
  }
});

// Wait for router to be ready
router.isReady.watch((ready) => {
  if (ready) {
    console.log("Router initialized, current path:", router.currentRoute.value?.path);
  }
});

// Cleanup watchers when done
unsubRoute();
unsubParams();
unsubQuery();

In Components

When navigating between /users/123 and /users/456, the view component is unmounted and remounted. If you want to react to route changes within the same instance (for example, with custom caching), you can watch ctx.router.current:

const UserPage = {
  setup(ctx) {
    const user = ctx.signal(null);
    const loading = ctx.signal(true);
    let unwatchRoute = null;

    // Function to load user data
    const loadUser = (id) => {
      if (!id) return;
      loading.value = true;
      fetchUser(id).then(data => {
        user.value = data;
        loading.value = false;
      });
    };

    return {
      user,
      loading,

      onMount: () => {
        // Load initial data using params getter
        loadUser(ctx.router.params.id);

        // Watch for route changes (same component, different user)
        // ctx.router.current is the currentRoute signal
        unwatchRoute = ctx.router.current.watch((route) => {
          const newId = route?.params?.id;
          if (newId && newId !== user.value?.id) {
            loadUser(newId);
          }
        });
      },

      onUnmount: () => {
        // Always clean up watchers!
        if (unwatchRoute) {
          unwatchRoute();
        }
      }
    };
  },

  template: (ctx) => `
    <div class="user-page">
      ${ctx.loading.value
        ? '<p>Loading...</p>'
        : `<h1>${ctx.user.value.name}</h1>`
      }
    </div>
  `
};

Important: When using .watch() on router state, always store the unsubscribe function and call it in onUnmount to prevent memory leaks.

Component Router API Reference:

Property Type Description
ctx.router.current Signal The currentRoute signal (watchable)
ctx.router.previous Signal The previousRoute signal (watchable)
ctx.router.params getter Current route params (direct value)
ctx.router.query getter Current query params (direct value)
ctx.router.path getter Current path (direct value)
ctx.router.meta getter Current route meta (direct value)
ctx.router.navigate() method Navigate to a route

Events

The router emits events throughout the navigation lifecycle. Events enable plugins and extensions.

Event Lifecycle Diagram

User triggers navigation
         │
         ▼
┌────────────────────────┐
│  router:beforeEach     │ ← Can BLOCK or REDIRECT
│  (guards run here)     │
└────────────────────────┘
         │ (if allowed)
         ▼
┌────────────────────────┐
│  router:beforeResolve  │ ← Can BLOCK or REDIRECT
│                        │   (show loading indicator)
└────────────────────────┘
         │
    [Load async components]
         │
         ▼
┌────────────────────────┐
│  router:afterResolve   │ ← (hide loading indicator)
└────────────────────────┘
         │
    [Unmount old component]
         │
         ▼
┌────────────────────────┐
│  router:afterLeave     │
└────────────────────────┘
         │
    [Update state]
         │
         ▼
┌────────────────────────┐
│  router:beforeRender   │ ← (start transition)
└────────────────────────┘
         │
    [Render new component]
         │
         ▼
┌────────────────────────┐
│  router:afterRender    │ ← (end transition)
└────────────────────────┘
         │
         ▼
┌────────────────────────┐
│  router:scroll         │ ← (handle scroll)
└────────────────────────┘
         │
         ▼
┌────────────────────────┐
│  router:afterEnter     │
└────────────────────────┘
         │
         ▼
┌────────────────────────┐
│  router:afterEach      │ ← (analytics, cleanup)
└────────────────────────┘

Complete Event Reference

Event Parameters Can Block Description
router:ready (router) No Router started and ready
router:beforeEach (context) Yes Before guards run
router:beforeResolve (context) Yes Before async component loading
router:afterResolve (context) No After components loaded
router:afterLeave (to, from) No After old component unmounted
router:beforeRender (context) No Before DOM rendering
router:afterRender (context) No After DOM rendering
router:scroll (context) No For scroll behavior
router:afterEnter (to, from) No After new component mounted
router:afterEach (to, from) No Navigation complete
router:error (error, to, from) No Navigation error
router:routeAdded (route) No Dynamic route added
router:routeRemoved (route) No Dynamic route removed

No router:notFound Event: When no route matches and no * route exists, the router emits router:error. Use a catch-all route for 404 handling.

Blocking Events (Context Object)

Events that can block receive a context object you can modify:

// Block navigation
router.emitter.on("router:beforeEach", (context) => {
  // context.to - Target RouteLocation
  // context.from - Source RouteLocation (or null)
  // context.cancelled - Set to true to cancel
  // context.redirectTo - Set to path or object to redirect

  if (shouldBlockNavigation(context.to)) {
    context.cancelled = true;
    return;
  }

  if (shouldRedirect(context.to)) {
    context.redirectTo = "/other-page";
    return;
  }
});

// Block before component loading
router.emitter.on("router:beforeResolve", (context) => {
  // Same context properties as beforeEach, plus:
  // context.route - The matched RouteDefinition

  if (maintenanceMode) {
    context.redirectTo = "/maintenance";
  }
});

Non-Blocking Events Usage

// Analytics
router.emitter.on("router:afterEach", (to, from) => {
  gtag("event", "page_view", {
    page_path: to.path,
    page_title: to.meta.title
  });
});

// Page transitions
router.emitter.on("router:beforeRender", ({ to, from }) => {
  const direction = determineDirection(to, from);
  document.body.dataset.transition = direction;
});

router.emitter.on("router:afterRender", () => {
  requestAnimationFrame(() => {
    document.body.dataset.transition = "";
  });
});

// Scroll restoration
router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
  if (savedPosition) {
    // Back/forward navigation - restore position
    window.scrollTo(savedPosition.x, savedPosition.y);
  } else if (window.location.hash) {
    // Anchor link - scroll to element
    const id = window.location.hash.slice(1);
    document.getElementById(id)?.scrollIntoView();
  } else {
    // New navigation - scroll to top
    window.scrollTo(0, 0);
  }
});

Dynamic Routes

Add, remove, and query routes at runtime.

Adding Routes

// Add a single route
const removeRoute = router.addRoute({
  path: "/new-feature",
  component: NewFeaturePage,
  meta: { addedDynamically: true }
});

// The route is immediately available
await router.navigate("/new-feature");

// Remove it later
removeRoute();

Removing Routes

// Remove by path
const wasRemoved = router.removeRoute("/new-feature");
console.log(wasRemoved); // true if found and removed, false otherwise

Querying Routes

// Check if route exists
if (router.hasRoute("/admin")) {
  console.log("Admin route is registered");
}

// Get all routes
const allRoutes = router.getRoutes();
console.log(allRoutes.map(r => r.path));
// ["/", "/about", "/users/:id", "*"]

// Get specific route
const userRoute = router.getRoute("/users/:id");
console.log(userRoute?.meta); // { requiresAuth: true }

Route Change Events

router.emitter.on("router:routeAdded", (route) => {
  console.log("Route added:", route.path);

  // Update sitemap, navigation menu, etc.
  updateNavigationMenu();
});

router.emitter.on("router:routeRemoved", (route) => {
  console.log("Route removed:", route.path);
});

Use Case: Micro-Frontends

// Load micro-frontend module and register its routes
async function loadMicroFrontend(name) {
  const module = await import(`./micro-frontends/${name}/index.js`);
  const { routes } = module;

  // Store remove functions for cleanup
  const removeFunctions = routes.map(route =>
    router.addRoute({
      ...route,
      path: `/${name}${route.path}`,
      meta: { ...route.meta, microFrontend: name }
    })
  );

  return () => removeFunctions.forEach(fn => fn());
}

// Usage
const unloadProducts = await loadMicroFrontend("products");
// Later: unloadProducts();

Scroll Behavior

The router automatically tracks scroll positions and provides hooks for custom scroll behavior.

Automatic Scroll Position Saving

The router saves { x, y } scroll positions per route path. When navigating back/forward (browser buttons), the saved position is available.

Custom Scroll Behavior

router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
  // savedPosition is available for back/forward navigation (popstate)
  // It's null for new navigation (click, router.navigate)

  if (savedPosition) {
    // Browser back/forward - restore exact position
    window.scrollTo({
      left: savedPosition.x,
      top: savedPosition.y,
      behavior: "instant"
    });
  } else if (window.location.hash) {
    // Hash/anchor navigation
    const hash = window.location.hash.slice(1);
    const element = document.getElementById(hash);
    if (element) {
      element.scrollIntoView({ behavior: "smooth" });
    }
  } else {
    // New page - scroll to top
    window.scrollTo({ top: 0, behavior: "smooth" });
  }
});

Scroll to Top on Every Navigation

router.emitter.on("router:scroll", () => {
  window.scrollTo(0, 0);
});

Preserve Scroll Position on Specific Routes

const preserveScrollRoutes = ["/search", "/feed"];

router.emitter.on("router:scroll", ({ to, from, savedPosition }) => {
  // Don't scroll on routes that should preserve position
  if (preserveScrollRoutes.includes(to.path)) {
    return;
  }

  // Normal scroll behavior
  if (savedPosition) {
    window.scrollTo(savedPosition.x, savedPosition.y);
  } else {
    window.scrollTo(0, 0);
  }
});

Next Steps

See Also


← Back to Configuration Next: Guards →