Riposte

Routing

riposte-router maps URLs to views for single-page apps. It’s built entirely on the core’s public API — useSyncExternalStore for the location, context for route params and the outlet, the DSL…

riposte-router maps URLs to views for single-page apps. It’s built entirely on the core’s public API — useSyncExternalStore for the location, context for route params and the outlet, the DSL for links — so it touches no internals and adds nothing you couldn’t build yourself; it just saves you from doing so.

Add the dependency (it pulls in the core transitively):

libraryDependencies += "io.github.edadma" %%% "riposte-router" % "0.0.1"

import io.github.edadma.riposte.*
import io.github.edadma.riposte.router.*

A router and some routes

Router establishes the routing context and tracks the current location. Inside it, Routes picks the best-matching route for the current URL and renders it. route pairs a path pattern with a view:

val App = view {
  Router() {
    Routes(
      route("/")(Home()),
      route("/about")(About()),
      route("/users/:id")(UserPage()),
      route("*")(NotFound()),
    )
  }
}

Routes picks the most specific match regardless of declaration order, so a static /users/new wins over the dynamic /users/:id even if :id is declared first. A "*" pattern is a catch-all — the idiomatic not-found route.

History vs. hash mode

Router defaults to History mode — clean URLs (/users/7) via the History API, which needs the server to fall back to index.html for unknown paths. For static hosting with no such fallback, use hash mode, which keeps everything after a # (/#/users/7):

Router(RouterMode.Hash) {
  Routes()
}

Params

A :name segment captures a path parameter. Read the matched params with useParams, which returns a Params (a Map[String, String]):

val UserPage = view {
  val id = useParams().getOrElse("id", "")
  p(s"User $id")
}

useParams reads the params of the nearest enclosing route, and params accumulate down a nested branch — a child route sees its own captures plus all of its ancestors’.

Alternatively, take the params directly in the route declaration:

route("/users/:id")(params => UserDetail(params("id")))

Link renders an <a> that navigates in-app — no full-page reload:

nav(
  Link("/", "Home"),
  Link("/about", "About"),
)

NavLink is a Link that adds an active CSS class when its target matches the current location. It’s curried — options first, then the children — and end = true requires an exact path match (otherwise a prefix match counts, so /users is active on /users/7 too):

nav(
  NavLink("/", activeClass = "current", end = true)("Home"),
  NavLink("/users", activeClass = "current")("Users"),
)

To navigate imperatively — after a form submit, say — call navigate. Pass replace = true to replace the current history entry instead of pushing a new one:

button(onClick := (_ => navigate("/users/42")), "Open user 42")
navigate("/login", replace = true)

Nested routes

A route can take child routes. The parent matches a prefix of the path and renders its matched child wherever its view places an Outlet; the child’s pattern is relative to the parent. index(...) declares the child shown when the parent’s own path is matched exactly:

Routes(
  route("/dashboard")(Dashboard())(
    index(Overview()),
    route("settings")(Settings()),
    route("users/:id")(UserDetail()),
  ),
)

val Dashboard = view {
  div(
    h1("Dashboard"),
    nav(
      NavLink("/dashboard/settings", end = true)("Settings"),
    ),
    Outlet,   // Overview, Settings, or UserDetail renders here
  )
}

This is how you build shared layouts: the parent route is the chrome (header, sidebar, nav), and Outlet is the hole the active child fills.

Query strings

useSearchParams reads and updates the query portion of the URL, in both History and hash modes. It returns the current params and a setter; the setter takes the new params and a replace flag:

val Search = view {
  val (params, setParams) = useSearchParams()
  val q = params.getOrElse("q", "")

  input(
    value := q,
    onInput := (e => setParams(Map("q" -> targetValue(e)), true)),
  )
}

Per-route error boundaries

route(...).catchErrors(fallback) wraps a route’s view in an error boundary: if rendering that route — or any descendant up to a nested route’s own boundary — throws, fallback(error) shows in its place instead of the failure tearing down the app. The route keeps matching, so fixing the cause and re-rendering recovers:

route("/report/:id")(Report())
  .catchErrors(err => div(cls := "error", s"Couldn't load report: ${err.getMessage}"))

Lazy routes

lazyView defers loading a view until it’s first rendered, backing onto JavaScript’s dynamic import() so the bundler can split that view into its own chunk. It takes a function returning a js.Promise[VNode] and an optional fallback shown while loading:

route("/admin")(
  lazyView(() => loadAdminPanel(), fallback = p("Loading…"))
)

Pair it with code-splitting in your build to keep the initial bundle small and load heavy routes on demand.

Scroll restoration

ScrollRestoration is a component that resets (and, for back/forward navigation, restores) the scroll position as the location changes — the behavior browsers do for free on full page loads but not for in-app navigation. Render it once, inside the Router:

val App = view {
  Router() {
    ScrollRestoration()
    Routes()
  }
}

Search

Esc
to navigate to open Esc to close