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
orDown
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 toIndex.init
,Index.update
andIndex.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.