Navigation
Overview
Navigation in Shopfoo is built on top of Feliz.Router, a type-safe client-side routing library for Fable/React applications. The routing logic lives in two main files:
Routing.fs— Defines thePagediscriminated union, URL encoding/decoding, and navigation helpers.View.fs— The application entry point, where the rootAppViewReact component uses its own Elmish loop to manage global concerns including URL changes viaReact.router.
The Page type
Page typeAll navigable pages are represented by a single discriminated union:
type Page =
| About
| Admin
| Home
| Login
| NotFound of url: string
| ProductIndex of filters: Filters
| ProductDetail of SKUEach case carries its own parameters. Notably, ProductIndex embeds a full Filters record (search term, sorting, category filters...) and ProductDetail carries a strongly-typed SKU.
Entry point: React.router
React.routerIn View.fs, the top-level AppView component wires everything together using React.router:
Three things happen here:
Path mode — The router uses the URL path (not hash-based routing).
URL change listener — When the browser URL changes,
Page.parseFromUrlSegmentsconverts the raw segments into a typedPagevalue, which is then dispatched as anUrlChangedmessage into the Elmish loop.Children rendering — The navbar, page content, and toast notifications are rendered as children of the router.
The model stores the current Page, and the view pattern-matches on it to render the appropriate page component:
Navigating between pages
Two helpers are defined in Routing.fs to trigger navigation, depending on the context:
From a view: Router.navigatePage
Router.navigatePageWhen navigation is triggered by a user interaction (e.g. a button click), the view calls:
This helper converts the Page into URL segments through the (|PageUrl|) active pattern (details below), then delegates to Feliz.Router's Router.navigatePath:
From an update function: Cmd.navigatePage
update function: Cmd.navigatePageWhen navigation must happen as an Elmish side effect (e.g., redirecting after login), the update function returns:
This is the Cmd counterpart, producing an Elmish command that triggers the same navigatePath call:
Both helpers share the same (|PageUrl|) conversion, ensuring a single source of truth for URL generation.
In anchor elements: prop.hrefRouted
prop.hrefRoutedFor <a> links that need both a visible href (for accessibility and right-click "copy link") and SPA navigation (preventing full page reload), a helper extension is defined:
It sets the href attribute for the browser and intercepts the click event via Router.goToUrl to perform client-side navigation instead. This is used extensively in the navbar breadcrumbs and filter tabs.
Navbar: breadcrumb-based navigation
The AppNavBar component renders a DaisyUI breadcrumb trail that adapts to the current page. A private Nav class encapsulates the breadcrumb item rendering logic:
If the item corresponds to the current page, it renders as plain text (no link).
Otherwise, it renders as an anchor using
prop.hrefRouted.
The breadcrumb structure reflects the page hierarchy. For example, a ProductDetail page for a book shows: Home > Products > Books > OL12345M.
Additional controls (user dropdown, language selector, theme switcher, admin gear icon, about icon) are injected as children of the navbar from the AppView.
URL encoding and decoding
Encoding: (|PageUrl|) active pattern
(|PageUrl|) active patternThe (|PageUrl|) active pattern converts a Page value into a PageUrl record containing URL segments and query parameters:
The PageUrl type offers a fluent API (WithQueryParam, WithQueryParamOptional, WithFiltersQueryParams) to build the query string incrementally. Only non-default values are serialized — for instance, Highlighting.Active (the default) produces no query parameter, while Highlighting.None serializes as highlight=no.
Decoding: Page.parseFromUrlSegments
Page.parseFromUrlSegmentsURL segments are parsed back into a Page using pattern matching:
This function leverages a layered system of active patterns (detailed in the next section).
Roundtrip property
A property-based test verifies that encoding and decoding are inverse operations:
The test does not exercise arbitrary Page values — it uses SanitizedPage, a record type that constrains all string fields (SKU values, tags, author IDs, search terms...) to an AlphaNumString type. Its FsCheck generator is designed to produce 1 to 32 alphanumeric characters (a-z, A-Z, 0-9), avoiding special characters that would break URL encoding or query string parsing. Since real-world pages are a subset of these sanitized pages (actual SKUs, tags, and search terms only contain alphanumeric characters), the test remains valid and guarantees that any realistic Page value survives a roundtrip through URL serialization and parsing.
Deep dive: active pattern composition for URL parsing
The URL parsing in Routing.fs is a good example of how F# active patterns can be composed to build a clean, declarative parser. Several layers of active patterns work together, each handling a specific concern.
Primitive helpers
At the base level, small utility patterns extract raw values:
(⏐Dashed⏐)
Splits a string on - into segments (total pattern)
(⏐Param⏐_⏐) key
Extracts the value of a query parameter by key
(⏐YesNo⏐_⏐)
Recognizes "yes" / "no" as bool
(⏐Col⏐_⏐)
Recognizes a column key like "name" as a Column
(⏐Desc⏐_⏐)
Recognizes the "desc" token
Domain-level patterns
These compose the primitives into domain-meaningful extractions:
(⏐SKU⏐_⏐)
Route segment
SKU option — recognizes "FS-42", "BN-978...", "OL123M"
(⏐Highlight⏐)
Query params
Highlighting — reads highlight param
(⏐MatchCase⏐)
Query params
CaseMatching — reads matchCase param
(⏐SearchTerm⏐)
Query params
string option — reads search param
(⏐Sort⏐)
Query params
(Column * SortDirection) option — parses "name-desc" via Dashed + Col + Desc
(⏐Category⏐)
Query params
BazaarCategory option
(⏐Author⏐)
Query params
OLID option
(⏐Tag⏐)
Query params
string option
Composite pattern: (|Filters|)
(|Filters|)The (|Filters|) pattern combines four sub-patterns at once using the & (AND) combinator:
It returns a function (Filters -> Filters) rather than a Filters value. This allows callers to apply it on top of default filters, which is how parseFromUrlSegments works:
Here, Route.Query extracts the query string, then Route.Filters, Route.Author, and Route.Tag are all applied simultaneously via &, each extracting its own concern from the same query parameters.
What makes this approach elegant
This layered active pattern design achieves several things:
Separation of concerns — Each pattern handles exactly one aspect (one query param, one URL segment format). They are small, testable, and reusable.
Declarative composition — The
&combinator lets you combine multiple extractions in a singlematcharm without nested conditionals or manual parameter lookups.Defaults via totality — Most domain-level patterns are total (not partial): they always return a value, falling back to a sensible default (e.g.
CaseInsensitive,Highlighting.Active). This means unrecognized or missing query params silently resolve to defaults, avoiding errors.Functional transformation —
(|Filters|)returning a function rather than a flat value enables a clean pipeline where category-specific parameters can be layered on top separately.Symmetry with encoding — The key constants (e.g.
"highlight","matchCase") appear in both the(|Param|_|)calls (decoding) and theWithQueryParamOptionalcalls (encoding), making the roundtrip relationship obvious and easy to maintain.
The overall result is that parseFromUrlSegments reads almost like a route table declaration — each match arm maps a URL pattern to a Page case, with the active patterns handling all the parsing details behind the scenes.
Last updated