United Kingdom: +44 (0)208 088 8978

Routing and Navigation with Elmish SPAs

Hello everyone! In this blog post we will look at a way to deal with navigation and routing when building a Single Page Application with Elmish. Please note that I assume you're at least somewhat familiar with the Model-View-Update pattern, and of course, Elmish.

Routing with SPAs

Unlike multiple page applications, SPAs consist of a single page. We update the contents of this page depending on user actions (or other events) to create the perception of navigating to another page. To find out more about SPAs and the benefits of this approach to web development, click here.

Since technically we are not being navigated to another page, the URL doesn't change unless we explicitly do something to change it. In most cases, it will be desirable to change the URL so that the user can see his exact location and maybe even copy the URL for future reference. All we're going to need is the Elmish browser package.

The code that I will walk you through is based on a SAFE Stack application. I have removed all the bits and pieces that are unnecessary for our purposes and added the code that we only actually need for this demonstration. You can install the template by running dotnet new -i SAFE.Template in command line. After this, you will be able to create a SAFE Stack app by running dotnet new safe.

Let's dive in...

We first need to open the Elmish.Navigation module:

open Elmish.Navigation 

Pages as Modules

To start off, let's implement three modules. Each of the modules will be representing a page in our application and will have a separate Model, an init function and a view function.

Our code will look like this:

module HomePage =
    type Model = { Title : string }
    let init () = { Title = "Welcome! You're in HOME PAGE. " }
    let view model dispatch = Content.content [] [ str model.Title ]

module AddressPage =
    type Model =
        { Address : string
          Postcode : string }

    let init () =
      { Address = "41 Liverpool Street, London"
        Postcode = "P21 1XX" }

    let view model dispatch =
        Content.content [] [
            str "Address Page"
            str (sprintf "Address: %s" model.Address)
            str (sprintf "Postcode: %s" model.Postcode)
        ]

module PersonPage =
    type Model = { Name : string }
    let init fullName = { Name = fullName }
    let view model dispatch =
        Content.content [] [
            str "Person Details"
            str (sprintf "Full Name: %s" model.Name)
        ]
  • HomePage module represents a page that will only have a string value, which will be initialized as "Welcome! You're in HOME PAGE." This "state" will then be used in its view function to render a header in the page.

  • AddressPage module has a bit more information attached to its, but what it does is not much different than HomePage. It will have some information stored in its model, which is initialized by its init function and displayed to the user by view.

  • In the PersonPage, we will hypothetically display the full name of a person. This name will be passed from our top-level view function through some form controls.

The "Actual" Model

In order to utilize these modules in our Elmish application, let's include them in our top-level Model and initialize them in our init. The code will look like this:

type Page = 
    | HomePage 
    | AddressPage 
    | PersonPage of string

type SubModel =
    | HomePageModel of HomePage.Model
    | AddressPageModel of AddressPage.Model
    | PersonPageModel of PersonPage.Model

type Model =
    { NameEntry: string
      CurrentPage: Page
      SubModel: SubModel }

type Msg = ChangePersonName of string

let init page : Model * Cmd<Msg> =
    let page = page |> Option.defaultValue HomePage

    let subModel =
        match page with
        | HomePage -> HomePageModel (HomePage.init ())
        | AddressPage -> AddressPageModel (AddressPage.init ())
        | PersonPage name -> PersonPageModel (PersonPage.init name)

    { CurrentPage = page
      SubModel = subModel
      NameEntry = "" }, Cmd.none

let update msg model =
    match msg with
    | ChangePersonName name -> { model with NameEntry = name }, Cmd.none

As you can see, the Model has two fields.

  • CurrentPage is of type Page.

    • Page is a discriminated union based on which we will decide which page to render.
  • SubModel is of a new type that we have declared called SubModel also.

    • This is a discriminated union and each of its cases represent one of the sub-pages.
  • We only have a single case in our Msg, which we will use to update the PersonPage's content.

  • The init function takes a Page option and returns a model and a command.

    • If the page is None we will set the it to HomePage using Option.defaultValue. This will be the case when we first run the application.
    • Based on this page, we will decide which sub-model to initialize. As you can see in the match statement, we use the sub-model's own init function to initialize it.
  • The update function will be used to update the PersonPage's content with a string value that will be passed to it.

Navigate and Render

In our top-level view function, let's implement the controls for navigation and logic for rendering the contents of the sub-models.

let view (model : Model) (dispatch : Msg -> unit) =
    Container.container [] [
        Label.label [] [ str "Name" ]
        Input.text [
            Input.Value model.NameEntry
            Input.Props [ OnChange (fun ev -> dispatch (PersonNameChanged !!ev.target?value)) ]
        ]
        let href = sprintf "#person/%s" model.NameEntry
        Button.a [ Button.Props [ Href href ] ] [ str "Person Page" ]
        Button.a [ Button.Props [ Href "#home" ] ] [ str "Home Page" ]
        Button.a [ Button.Props [ Href "#address" ] ] [ str "Address Page" ]

        match model.SubModel with
        | HomePageModel m -> HomePage.view m dispatch
        | AddressPageModel m -> AddressPage.view m dispatch
        | PersonPageModel m -> PersonPage.view m dispatch
    ]
  • We use Input.text to create a text box. Whenever the text inside this textbox changes, we update the NameEntry field of our model.

  • The href value is used to generate a URL for the Person Page navigation button. If the name endered in the textbox is Alican Demirtas, this value will be "#person/Alican Demirtas".

  • The buttons are used for navigating to Person Page, Home Page and Address Page respectively.

  • The pattern matching is for deciding which page's view function to call inside this top-level view function, and therefore render its contents to the user.

Parse and Update

In order to parse the URLs that we are setting with our navigation buttons, let's define a new module to do that. We will need to open Elmish.UrlParser in it.

module Navigation =
    open Elmish.UrlParser

    let pageParser : Parser<_,_> =
        oneOf [
            map HomePage (s "home")
            map AddressPage (s "address")
            map PersonPage (s "person" </> str)
        ]

    let urlUpdate (page: Page option) _ =
        let page = page |> Option.defaultValue HomePage
        let model, _ = Some page |> init
        { model with CurrentPage = page }, Cmd.none
  • We will use pageParser to Parse the incoming URLs into Pages. I will not get into too much detail, but you can think of it as:
    • If the URL contains "home" it will be parsed into HomePage.
    • If it contains "address", it will be parsed into AddressPage.
    • If it contains "person", it will be parsed into PersonPage and the second value in the URL will be passed to it.
    • "person/{second-value}"
  • The urlUpdate function takes in a page option, much like our init function. It returns a model and a command, with the model's CurrentPage field set to a new page.

Pipe It Through!

One last thing we need to do is to pipe our program through an Elmish function called Program.toNavigable. This is where we will make use of the two functions that we have defined inside our Navigation module. The last lines of our file will look like this:

Program.mkProgram init update view
|> Program.toNavigable (UrlParser.parseHash Navigation.pageParser) Navigation.urlUpdate
|> Program.withReactBatched "elmish-app"
|> Program.run

If you have followed along, you will now notice that as you click on the buttons, the content of the page and the URL changes. With just a little bit of styling it will look like this:

We now have a single page application and as we "navigate" between the pages, the URL changes.

If you'd like to see the full code for this simple navigation example, please visit this GitHub repo.

I hope I was able to bring you some value and you have enjoyed this blog post!