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 itsview
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 thanHomePage
. It will have some information stored in itsmodel
, which is initialized by itsinit
function and displayed to the user byview
. -
In the
PersonPage
, we will hypothetically display the full name of a person. This name will be passed from our top-levelview
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 typePage
.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 calledSubModel
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 thePersonPage
's content. -
The
init
function takes aPage option
and returns a model and a command.- If the
page
isNone
we will set the it toHomePage
usingOption.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 thematch
statement, we use the sub-model's owninit
function to initialize it.
- If the
-
The
update
function will be used to update thePersonPage
'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 theNameEntry
field of our model. -
The
href
value is used to generate a URL for thePerson Page
navigation button. If the name endered in the textbox isAlican Demirtas
, this value will be"#person/Alican Demirtas"
. -
The buttons are used for navigating to
Person Page
,Home Page
andAddress Page
respectively. -
The pattern matching is for deciding which page's
view
function to call inside this top-levelview
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 intoHomePage
. - If it contains
"address"
, it will be parsed intoAddressPage
. - If it contains
"person"
, it will be parsed intoPersonPage
and the second value in the URL will be passed to it. - "person/{second-value}"
- If the URL contains
- The
urlUpdate
function takes in apage option
, much like ourinit
function. It returns a model and a command, with the model'sCurrentPage
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!