Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pages and Navigation

An application is more than one screen. Mar’s unit of composition is the page: one route, one MVU loop, one module. This chapter covers how pages declare their routes, how typed parameters and protected pages work, and how navigation stays inside the architecture.

One page, one loop, one file

Each page module exports a page value built by a Page.* combinator, packaging the five MVU pieces with a path and a title:

page : Page
page =
    Page.create
        { path = "/"
        , title = "Team Notes"
        , init = init
        , update = update
        , view = view
        , subscriptions = always Sub.none
        }

Main.mar lists every page (and every service) explicitly; the runtime dispatches by path:

main : Cmd ()
main =
    App.fullstack
        { services = Backend.Notes.services
        , pages =
            [ Frontend.SignIn.page
            , Frontend.Home.page
            , Frontend.NoteDetail.page
            ]
        }

No auto-discovery, no filesystem routing conventions to memorize: the list is the app, and a page you forgot to register is a page that visibly is not there.

Pages are deliberately independent, and they live on a stack. Going somewhere new runs that page’s init from scratch, so a screen never opens showing the previous visit’s stale data. Going back hands you the screen you left, model intact, with no refetch: the pages below the top did not stop existing while you were away. What you see and what you get come from one rule — the same answer decides which way the screens slide and whether the destination is fresh.

Data that must outlive a page lives on the server. A client-side shared store is on Mar’s roadmap; the current model is the simple one.

The combinators encode the page’s contract

The choice of combinator states, in the type system, what a page needs before it can exist:

Page.create           -- public, static path;   init : (Model, Cmd Msg)
Page.protected        -- needs a session;       init : User -> (Model, Cmd Msg)
Page.dynamic          -- path carries args;     init : args -> (Model, Cmd Msg)
Page.dynamicProtected -- both;                  init : User -> args -> (Model, Cmd Msg)

Look at what Page.protected does to init’s type: the page cannot be initialized without a User. This is the “make impossible states unrepresentable” doctrine applied to auth. There is no “check if logged in” conditional to forget, no redirect middleware to misconfigure per route. A protected page’s very init demands a proof of session, and the runtime supplies it (or redirects to sign-in) before your code runs.

A route can be presented instead of pushed

The combinators above all push: the destination replaces what was on screen, and Back brings the previous screen back. That is the right model for a hierarchy — a list, an item, a detail of the item — and the wrong one for a task.

Taking attendance, composing a message, editing a record: these have a beginning and an end. The reader finishes or abandons them; they are not places. Page.sheet wraps any of the four combinators and changes one thing, how the page is shown:

page : Page
page =
    Page.sheet
        (Page.dynamicProtected
            { path = Frontend.Routes.takeAttendance
            , title = "Take attendance"
            , init = init
            , update = update
            , view = view
            , subscriptions = \_ _ _ -> Sub.none
            }
        )

Navigating there leaves the screen it was reached from on display and lays this one over it in a sheet. It stays a real route: same path, same history entry, same deep link. Back, Escape and a tap outside dismiss it, and Nav.dismiss is the same verb for the sheet’s own Cancel or Done button.

A decorator rather than a fifth combinator, and that is not only economy: the four names already encode the two questions a page answers (does it need a session, does its path carry arguments). Presentation is a third, independent question, and hanging it off the side keeps Page.sheetDynamicProtected from ever existing.

The affordance has to agree with the presentation. A navigationLink draws a row with a chevron, and a chevron is a promise: this pushes a screen, and Back brings you home. Point one at a Page.sheet and the promise is broken the moment it slides up from the bottom instead. A presented route is opened by something that reads as an action — a button, a toolbar item — never by a disclosure row.

Two consequences shape how you write the page:

  • Opened cold it renders full screen. A shared link, a reload or a bookmark has no page behind it to present over, so the route mounts like any other. Write it to read on its own, and give it a header rather than assuming a navigation bar is there — a presented route is not on the stack, so it has none.
  • The covered page comes back exactly as it was, model and scroll intact, and nothing refetches on dismissal. If the task changed data that page is displaying, that page will be stale until something asks it to reload.

UI.sheet covers the other case: a small form the parent page owns — name a class, add a student — where the open/closed flag is a field in the parent’s Model. The question that separates them is whether the thing has its own model and its own URL, or is a field and two buttons.

Typed routes

Dynamic pages carry parameters in the path, declared with typed placeholders and kept in a Routes module both link and page import:

-- Frontend/Routes.mar
home       = "/"
noteDetail = "/notes/{id:Int}"
-- Frontend/NoteDetail.mar
page =
    Page.dynamic
        { path = Frontend.Routes.noteDetail
        , ...
        }

init : { id : Int } -> (Model, Cmd Msg)
init args =
    ( { note = Nothing }
    , Service.call Shared.getNote { id = args.id } Fetched
    )

The placeholder {id:Int} is parsed, converted, and delivered to init as a record field, already an Int. A URL with garbage where the id belongs never reaches your code. And because links and pages share the one declaration, a route rename is a compile-time event, not a 404 discovered by users.

Two ways to move, both inside the loop:

  • In a view: navigationLink renders a tappable row that pushes its destination. Declarative, no handler needed.
  • In update: navigation is a Cmd, like any other effect.
Saved (Ok note) ->
    ( model, Nav.replaceTo (Frontend.Routes.noteDetail note.id) )

Nav.pushTo adds a history entry (Back returns here); Nav.replaceTo swaps the current one (Back skips it; right after “create”, you rarely want Back to resurrect the blank form). Nav.dismiss closes a presented route, or steps back one screen, and does nothing at the app’s first screen. After sign-in, Auth.completeSignIn returns the user to wherever a 401 interrupted them.

Every one of them goes through history rather than reaching for the DOM. The URL is what says which screen is showing, so a dismissal that quietly removed a panel would leave the two disagreeing, and Back would then “return” to a screen already in front of the reader.

Note what navigation is not: it is not a function you call for its side effect mid-computation. It is a value update returns, which means it obeys the same reasoning as everything else. A test can assert “this message leads to this navigation” by inspecting the returned command, no browser required.

The app, so far

You now have the entire frontend half of Mar: pure state machines per page, effects as commands, events as subscriptions, and routes as types. What is missing is the other half of every real page: Shared.listTasks and friends, the services those Service.calls have been invoking on faith. Part II’s second act crosses the wire.