Skip to content

Routing workspace

The previous tutorials each had one main component. This example moves into application structure: an outer shell stays mounted, the content on the right changes through Outlet based on the current path, and pages write to local history through Navigate.

router demo

This recording is generated by docs/tapes/router.tape and runs examples/routing/router.rs. It enters the project list, opens a dynamic detail page, then goes back and forward through history.

cargo run --example router

Press 1, 2, and 3 to switch pages. In the project list, press j/k to move and Enter to open a detail page. Press b/f to go back and forward, r to replace the current history entry with the home page, and q to exit.

RouterProvider owns history; Outlet consumes the current path; Navigate writes new history entries.

routes! uses declarative syntax close to element!. The parent route renders the shell; child routes are rendered by the Outlet inside the shell.

let routes = routes! {
    "/" => AppShell {
        "/" => OverviewPage,
        "/projects/:slug" => ProjectDetailPage,
        "/projects" => ProjectsPage,
        "/activity" => ActivityPage,
    },
};

element!(RouterProvider(
    routes: routes,
    index_path: "/",
))
.fullscreen()
.await
.expect("Failed to run the application");

Declare dynamic routes before static prefix routes. /projects/:slug should come before /projects; otherwise /projects/boreal may match the /projects prefix first.

AppShell registers global navigation keys and places an Outlet on the right. When the path changes, the shell does not need to unmount; only the page inside Outlet changes. The key structure is shown below with style and navigation list details omitted.

#[component]
fn AppShell(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let mut navigate = hooks.use_navigate();
    let mut exit = hooks.use_exit();

    hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
        let Event::Key(key) = event else {
            return EventResult::Ignored;
        };
        if key.kind != KeyEventKind::Press {
            return EventResult::Ignored;
        }

        match key.code {
            KeyCode::Char('1') => navigate.push("/"),
            KeyCode::Char('2') => navigate.push("/projects"),
            KeyCode::Char('3') => navigate.push("/activity"),
            KeyCode::Char('b') => navigate.back(),
            KeyCode::Char('f') => navigate.forward(),
            KeyCode::Char('r') => navigate.replace("/"),
            KeyCode::Char('q') | KeyCode::Char('Q') => exit(),
            _ => return EventResult::Ignored,
        }

        EventResult::Consumed
    });

    element!(
        View(flex_direction: Direction::Horizontal) {
            Border { /* navigation */ }
            View(width: Constraint::Fill(1)) {
                Outlet
            }
        }
    )
}

Here push adds a history entry, replace overwrites the current entry, and back / forward only move the current pointer. The history lifetime follows RouterProvider; it is not process-wide global state.

The project list page writes the selected project into the path and carries one navigation state payload with push_with_state:

let project = PROJECTS[selected.get()];
navigate.push_with_state(
    &format!("/projects/{}", project.slug),
    RouteNotice {
        message: format!("selected: {}", project.name),
    },
);

The detail page reads slug from the path, then tries to read the RouteState carried by this navigation:

let slug = {
    let params = hooks.use_params();
    params.get("slug").cloned().unwrap_or_default()
};

let notice = hooks
    .try_use_route_state::<RouteNotice>()
    .map(|state| state.message.clone())
    .unwrap_or_else(|| "opened without route state".to_string());

RouteState is “context attached to this navigation.” It is useful for carrying selection source, temporary notices, or lightweight payloads when moving from list to detail. It is not a global store and should not replace URL params.

navigate.push(path) and navigate.replace(path) clear the current route state. Only push_with_state and replace_with_state carry state explicitly.

That semantic is important: when moving from a detail page with state to an ordinary page, old state should not leak forward. Route state should be intentionally carried like a navigation parameter, not implicitly inherited from the previous page.

Next, read the routing core model to break down the boundaries between RouterHistory, RouteContext, and Route.