United Kingdom: +44 (0)208 088 8978

Elmish Navigation with Feliz.Router

This week Ryan shows you how to easily add multi-page navigation to your SAFE Stack apps using Feliz.Router!

We're hiring Software Developers

Click here to find out more

One of the questions that often comes up in our online courses is how to go about adapting an Elmish app such as the SAFE Template to handle multi page routing.

Thankfully this is made extremely easy with the wonderful Feliz.Router library from Zaid.

Setting up

Firstly we need to create a new SAFE app, initialise it and install Feliz.Router into the client project.

We use Femto to install the library as this takes care of both the nuget package and any associated npm libraries.

 dotnet new install SAFE.Template::4.1.1
 dotnet new SAFE
 dotnet tool restore
 cd src/Client
 dotnet femto install Feliz.Router
 cd ../..

Create some pages

For this demo we will just duplicate the SAFE template's out-of-the-box Todo app module.
Rename these files from Index.fs to Page1.fs and Page2.fs and change the module names to match. ie

module Page1

open Elmish
// etc...

Also change their UI lables to make them distinguishable, i.e.

//...
Bulma.heroBody [
    Bulma.container [
        Bulma.column [
            column.is6
            column.isOffset3
            prop.children [
                Bulma.title [
                    text.hasTextCentered
                    prop.text "Page 1" // <---- Set this appropriately
                ]
                containerBox model dispatch
            ]
        ]
    ]
]
//...

Set up routing

Create a new module called Index. Make sure this is below the Page modules as it needs to reference them.

You can change the order of modules in the Client.fsproj file, or in Visual Studio by selecting the file in Solution Explorer and pressing Alt + Up or Down

Firstly we will open Feliz.Router and create a couple of types to represent our URLs and Pages

module Index

open Elmish
open Feliz
open Feliz.Router

type Url =
    | Page1
    | Page2

type Page =
    | Page1 of Page1.Model
    | Page2 of Page2.Model
    | NotFound

Our model just needs to hold the current URL and Page

type Model =
    {
        CurrentUrl : Url option
        CurrentPage : Page
    }

Our message type will be either a Page1Msg / Page2Msg message that needs forwarding to a child page or a UrlChanged message that we can parse to perform navigation.

type Msg =
    | Page1Msg of Page1.Msg
    | Page2Msg of Page2.Msg
    | UrlChanged of Url option

Url paths are split and delivered as an array of data which we can pattern match against.

See the docs for a fuller example

let tryParseUrl = function
    | [] | [ "page1" ] -> Some Url.Page1
    | [ "page2" ] -> Some Url.Page2
    | _ -> None

Lets create a function to initialise a new page from a url

let initPage url =
    match url with
    | Some Url.Page1 ->
        let page1Model, page1Msg = Page1.init ()
        { CurrentUrl = url; CurrentPage = (Page.Page1 page1Model) }, page1Msg |> Cmd.map Page1Msg
    | Some Url.Page2 ->
        let page2Model, page2Msg = Page2.init ()
        { CurrentUrl = url; CurrentPage = (Page.Page2 page2Model) }, page2Msg |> Cmd.map Page2Msg
    | None ->
        { CurrentUrl = url; CurrentPage = Page.NotFound }, Cmd.none

Our Elmish init function will simply get the current URL, try to parse it and load the page.

let init () : Model * Cmd<Msg> =
    Router.currentPath()
    |> tryParseUrl
    |> initPage

The update function either forwards messages to child pages or initialises a new page using our helper from earlier

let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg, model.CurrentPage with
    | Page1Msg page1Msg, Page.Page1 page1Model  ->
        let newPage1Model, newPage1Msg = Page1.update page1Msg page1Model
        { model with CurrentPage = Page.Page1 newPage1Model }, newPage1Msg |> Cmd.map Page1Msg
    | Page2Msg page2Msg, Page.Page2 page2Model  ->
        let newPage2Model, newPage2Msg = Page2.update page2Msg page2Model
        { model with CurrentPage = Page.Page2 newPage2Model }, newPage2Msg |> Cmd.map Page2Msg
    | UrlChanged urlSegments, _ ->
        initPage urlSegments
    | _ ->
        model, Cmd.none

Finally, the view function uses a React.router element which is configured to dispatch UrlChanged messages when navigation is performed. Its children property is a singleton list containing the view corresponding to the current page (which is determined by the model).

let view (model: Model) (dispatch: Msg -> unit) =
    React.router [
        router.pathMode
        router.onUrlChanged (tryParseUrl >> UrlChanged >> dispatch)
        router.children [
            match model.CurrentPage with
            | Page.Page1 page1Model ->
                Page1.view page1Model (Page1Msg >> dispatch)
            | Page.Page2 page2Model ->
                Page2.view page2Model (Page2Msg >> dispatch)
            | Page.NotFound ->
                Html.p "Not found"
        ]
    ]

This page is already hooked into the Elmish loop because default SAFE Template configuration in Client/App.fs, which we haven't changed, refers to Index.init, Index.update and Index.view.

Try it out

In your terminal at the solution root execute

dotnet run

This should start the app. Be patient as this can take a while, especially the first time you launch it.

Once up and running, if you visit http://localhost:8080/ you should find Page 1 (as indicated by the UI label we set earlier). This is because our tryParseUrl function routes both and empty path ([]) and /page1 to Page 1.

If you add /page2 to the URL, you should navigate there. Switching to /page1 will return you to Page 1, and any other path should show you the "Not found" HTML.

Conclusion

As you can see, Feliz.Routing is easy to understand and configure. It is very flexible and I encourage you to read the docs to see what else it can do. Also check out Zaid's excellent Elmish Book which is free to read online and is packed with all the info you could ever need about Elmish SPAs, including a section about routing and navigation.

As usual a full sample of the project described here is available on our Github.