URL-Backed Tabs with Sub Pages¶
The tab_spa.py example combines NiceGUI tabs with sub-page routing. It keeps one application shell and one set of tab panels mounted while the browser URL identifies the active view.
The example targets NiceGUI 3.16.0. In this design, tabs are the visible navigation and content mechanism; ui.sub_pages is a URL-matching adapter whose route builders update the tab state instead of rendering route content inside the router.
Responsibility Map¶
| Surface | Responsibility |
|---|---|
root() |
Creates one client-local shell, reads the initial URL, and connects the tabs, panels, and router. |
ui.tabs |
Holds the selected tab name and emits user selection changes. |
ui.tab_panels |
Displays the panel whose name matches the selected tab. |
ui.sub_pages |
Matches URL paths, extracts route parameters, and invokes the corresponding route callback without a full page reload. |
NavigationState |
Retains the concrete report path represented by the shared reports tab. |
ui.navigate.to() |
Changes the browser location so the sub-pages router can resolve the destination. |
The separation matters because a tab name is not always a URL. Static tabs use their route as their name, but every /reports/{report_id} URL maps to the single reports tab and panel.
Route and Panel Mapping¶
| Browser path | Tab value | Panel value | Route callback effect |
|---|---|---|---|
/ |
/ |
/ |
Selects the overview panel. |
/projects |
/projects |
/projects |
Selects the projects panel. |
/reports/a |
reports |
reports |
Stores /reports/a and selects the reports panel. |
/reports/b |
reports |
reports |
Stores /reports/b and selects the reports panel. |
/settings |
/settings |
/settings |
Selects the settings panel. |
tab_name_for_route() is the translation boundary. It preserves static route names, collapses concrete report routes to reports, and returns / for other paths.
Initial Page Construction¶
root() reads ui.context.client.sub_pages_router.current_path before creating the navigation controls. normalize_route() removes query strings, fragments, and trailing slashes so /projects/ and /projects select the same tab.
For a direct request to /reports/b, the initial route produces two values:
active_report_pathbecomes/reports/b.- the selected tab and panel become
reports.
The initial tabs.set_value(...) call occurs before tabs.on_value_change(navigate) is registered. Initial selection therefore establishes the shell state without treating page construction as a user navigation. Passing the same initial tab value to ui.tab_panels aligns the content container with the tabs from the first render.
All panel builders run during shell construction. Switching tabs changes the selected panel; it does not rerun overview_page(), projects_page(), report_page(), or settings_page(). Their element state remains client-local for the lifetime of that shell.
Tab-Originated Navigation¶
The tab change handler receives the selected tab name. Static tab names are already destinations. The reports tab resolves through state.active_report_path, which supplies the last concrete report URL:
destination = state.active_report_path if tabname == REPORTS_TAB else tabname
ui.navigate.to(destination)
ui.navigate.to() performs the route transition. The sub-pages router then matches the new location and invokes a callback that selects the corresponding tab. Because the panel container is associated with tabs, the visible panel follows that selected value.
URL-Originated Navigation¶
The route callbacks contain no page markup. They translate router matches back into the visible state:
def route_reports(report_id: str) -> None:
state.active_report_path = f"/reports/{report_id}"
tabs.set_value(REPORTS_TAB)
This direction handles direct links and browser back or forward navigation. A URL such as /reports/b supplies report_id="b"; the callback reconstructs the normalized concrete path, updates report-bound labels through NavigationState, and selects the shared reports panel.
The router element is hidden because it is not the content container in this example:
Normally, ui.sub_pages clears and rebuilds its own children when a route changes. Here its builders only mutate state outside that container, so hiding the empty routing element does not hide the tab-panel content.
Parameterized Report State¶
NavigationState.active_report_path separates tab identity from route identity. The reports tab always has the stable value reports, while the state records /reports/a, /reports/b, or another matched report route.
This provides two forms of continuity:
- A direct report URL selects the correct tab and report during initial construction.
- Leaving the reports tab and selecting it again during the same client lifetime returns to the last visited report.
The state is page-local, not durable storage. Reloading a non-report URL creates a new NavigationState and restores DEFAULT_REPORT_PATH. Shareable report identity remains durable because report pages encode it in the URL.
Behavioral Boundaries¶
TAB_ROUTEScontains concrete routes whose path and tab identity are the same. Parameterized route families require a stable synthetic tab name such asreports.normalize_route()intentionally ignores query parameters and fragments for tab selection. Route callbacks would need matching parameters if those values affected panel state.- The hidden router also hides its built-in 404 output. As written, an unmatched path selects the overview panel through
tab_name_for_route()while the router's not-found content remains invisible. - Panels are mounted together, so expensive panel construction still occurs during the initial shell build. Lazy or route-specific construction requires a different content ownership model.
- The pattern preserves the shell only for navigation handled by the current
ui.sub_pagesrouter. A full reload creates a new client and rebuilds all page-local state.