docs/advanced/routing
Capabilities · Navigation

In-app routing

Describe pages as an enum, expose the current page and its params as two Signals, and get back / forward from a fixed-capacity history stack.

8 min read

The Router model

ui.fx.Router(Page) is an enum-driven, in-memory router, not a web URL router. It exposes two Signals, current: *Signal(Page) and params: *Signal(RouteParams), and keeps a history stack of up to 64 entries.

app.zig
const std = @import("std");
const ui = @import("ui");

const Page = enum { welcome, editor, settings };
const Router = ui.fx.Router(Page);

fn mountApp(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    // The router's two Signals are created in `scope`, so pass a scope that
    // lives as long as the window. The router value itself is large (~290 KB of
    // inline history), so keep it in Cx-owned state and pass the pointer around.
    const router = try cx.bindState(Router, try Router.init(scope, .welcome));

    const shell = try ui.box(cx, .{
        .width = .fill(),
        .height = .fill(),
        .direction = .column,
    }, .{});
    // ...append a toolbar and the page host (see "Rendering pages")
    _ = router;
    return shell;
}
navigate.zig
var params: ui.fx.router.RouteParams = .{};
try params.set("file", "draft.md"); // copies the bytes; errors on overflow

router.navigate(.editor, params); // page + params
router.navigateTo(.settings); // page, empty params

_ = router.back(); // false when already at the oldest entry
_ = router.forward(); // false when there is no forward branch

if (router.canGoBack()) {
    // e.g. enable a Back button
}

RouteParams.set copies the key and value bytes into its own buffers, so later changes to the caller’s buffer don’t affect it; setting an existing key overwrites the value. Capacity is fixed, and overflow returns an error instead of truncating:

LimitValueOn overflow
Params per entrymax_params = 8error.TooManyParams
Key lengthmax_key_len = 64error.KeyTooLong
Value lengthmax_value_len = 512error.ValueTooLong

navigate, back and forward all write both Signals. canGoBack() / canGoForward() are plain reads, not Signals. To drive the router from a handler, keep the pointer in a cx.bindState struct:

back_button.zig
const Nav = struct {
    router: *Router,

    fn goBack(self: *Nav) void {
        _ = self.router.back();
    }
};

const nav = try cx.bindState(Nav, .{ .router = router });
const back_button = try ui.widgets.Button(.{
    .label = "Back",
    .on_click = cx.on(Nav, nav, Nav.goBack),
}).mount(scope, cx);

Rendering pages

The current page is a Signal(Page); switch page subtrees on it with ui.Match. Match gives each page a child Scope; on a switch it disposes the old Scope first (its Signals, Effects and resources go with it), then frees the old nodes and mounts the new page.

pages.zig
const page_host = try ui.box(cx, .{
    .width = .fill(),
    .height = .fill(),
}, .{});

// Match appends the page to page_host and rebuilds it when router.current
// changes; each page gets a fresh child Scope that is disposed on leave.
_ = try ui.Match(Page, scope, page_host, router.current, cx, struct {
    fn build(child_scope: *ui.Scope, c: *ui.Cx, page: Page) anyerror!*ui.Node {
        return switch (page) {
            .welcome => mountWelcome(c, child_scope),
            .editor => mountEditor(c, child_scope),
            .settings => mountSettings(c, child_scope),
        };
    }
}.build);

Match rebuilds only when the enum value changes: navigating from .editor to .editor with different params keeps the page mounted. A page that depends on params should subscribe to router.params itself:

editor_page.zig
// editor -> editor with other params does not rebuild the page,
// so subscribe to the params Signal inside the page.
try scope.createEffect(.{ .params = router.params }, struct {
    fn run(ctx: anytype) void {
        // get() returns a copy; keep it in a local so the slices stay valid.
        const params = ctx.params.get();
        const file = params.get("file") orelse "untitled.md";
        std.log.info("editor shows {s}", .{file});
    }
}.run);

History rules

HISTORY STACK
back / forward only move the cursor; a navigate after back first cuts history_len at the cursor, then pushes the new entry.
  • ✓

    New navigation truncates the forward branch. Navigating after back discards the old forward entries.

  • ✓

    Every navigate pushes. The same page with different params (or even an identical target) becomes a new entry.

  • ✓

    At 64 entries, the older half goes. Before the 65th push the newest 32 are kept and the cursor shifts with them.

  • ✓

    No URLs. Deep links and system URL schemes must be mapped to navigate by the app.

CAPACITY 64
The drop is batched: instead of evicting one entry per push, it frees half the capacity at once.

Tracing navigation

The router doesn’t log on its own. To debug navigation, write the relevant transitions to the current Cx’s Console under a router scope; DevTools Console can then filter by scope and show the level and call site.

trace.zig
router.navigate(.editor, params);
cx.console().scoped("router").debug("navigate -> {s}", .{@tagName(Page.editor)});

See DevTools for the full Console API.

zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01