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. Each has its own Model; navigating instantiates the new page’s init and discards the old page’s state. (Navigate back and init runs again, refetching. 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.

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). After sign-in, Auth.completeSignIn returns the user to wherever a 401 interrupted them.

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.