How do I create multi-page applications with routing and the useElmish hook?
UseElmish is a powerful package that allows you to write standalone components using Elmish. A component built around the UseElmish hook has its own view, state and update function.
In this recipe we add routing to a safe app, and implement the todo list page using the UseElmish hook.
1. Installing dependencies
Install Feliz.Router in the Client project
dotnet paket add Feliz.Router -p Client
Install Feliz.UseElmish in the Client project
cd src/Client
dotnet femto install Feliz.UseElmish
Open the router in the client project
open Feliz.Router
2. Extracting the todo list module
Create a new Module TodoList in the client project. Move the following functions and types to the TodoList Module:
- Model
- Msg
- todosApi
- init
- todoAction
- todoList
Also open Shared, Fable.Remoting.Client, Elmish and Feliz.
module TodoList
open Shared
open Fable.Remoting.Client
open Elmish
open Feliz
type Model = { Todos: Todo list; Input: string }
type Msg =
| GotTodos of Todo list
| SetInput of string
| AddTodo
| AddedTodo of Todo
let todosApi =
Remoting.createApi ()
|> Remoting.withRouteBuilder Route.builder
|> Remoting.buildProxy<ITodosApi>
let init () =
let model = { Todos = []; Input = "" }
let cmd = Cmd.OfAsync.perform todosApi.getTodos () GotTodos
model, cmd
let update msg model =
match msg with
| GotTodos todos -> { model with Todos = todos }, Cmd.none
| SetInput value -> { model with Input = value }, Cmd.none
| AddTodo ->
let todo = Todo.create model.Input
let cmd = Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo
{ model with Input = "" }, cmd
| AddedTodo todo ->
{
model with
Todos = model.Todos @ [ todo ]
},
Cmd.none
let private todoAction model dispatch =
Html.div [
prop.className "flex flex-col sm:flex-row mt-4 gap-4"
prop.children [
Html.input [
prop.className
"shadow appearance-none border rounded w-full py-2 px-3 outline-none focus:ring-2 ring-teal-300 text-grey-darker"
prop.value model.Input
prop.placeholder "What needs to be done?"
prop.autoFocus true
prop.onChange (SetInput >> dispatch)
prop.onKeyPress (fun ev ->
if ev.key = "Enter" then
dispatch AddTodo)
]
Html.button [
prop.className
"flex-no-shrink p-2 px-12 rounded bg-teal-600 outline-none focus:ring-2 ring-teal-300 font-bold text-white hover:bg-teal disabled:opacity-30 disabled:cursor-not-allowed"
prop.disabled (Todo.isValid model.Input |> not)
prop.onClick (fun _ -> dispatch AddTodo)
prop.text "Add"
]
]
]
[<ReactComponent>]
let todoList model dispatch =
Html.div [
prop.className "bg-white/80 rounded-md shadow-md p-4 w-5/6 lg:w-3/4 lg:max-w-2xl"
prop.children [
Html.ol [
prop.className "list-decimal ml-6"
prop.children [
for todo in model.Todos do
Html.li [ prop.className "my-1"; prop.text todo.Description ]
]
]
todoAction model dispatch
]
]
4. Add the UseElmish hook to the TodoList view function
open Feliz.UseElmish in the TodoList Module
open Feliz.UseElmish
...
In the todoList module, rename the function todoList to view, and remove the private access modifier.
On the first line, call React.useElmish, passing it the init and update functions. Bind the output to model and dispatch
let view model dispatch =
let model, dispatch = React.useElmish (init, update, [||])
...
-let containerBox model dispatch =
+let view model dispatch =
+ let model, dispatch = React.useElmish (init, update, [||])
...
Replace the arguments of the function with unit, and add the ReactComponent attribute to it
[<ReactComponent>]
let view () =
...
+ [<ReactComponent>]
- let view model dispatch =
+ let view () =
...
5. Add a new model to the Index module
In the Index module, create a model that holds the current page
type Page =
| TodoList
| NotFound
type Model =
{ CurrentPage: Page }
6. Initializing the application
Create a function that initializes the app based on an url
let initFromUrl url =
match url with
| [ "todo" ] ->
let model = { CurrentPage = TodoList }
model, Cmd.none
| _ ->
let model = { CurrentPage = NotFound }
model, Cmd.none
Create a new init function, that fetches the current url, and calls initFromUrl.
let init () = Router.currentUrl () |> initFromUrl
7. Updating the Page
Add a Msg type, with an PageChanged case
type Msg =
| PageChanged of string list
update function, that reinitializes the app based on an URL
let update msg model =
match msg with
| PageChanged url -> initFromUrl url
8. Displaying pages
Add a pageContent function to the Index module, that returns the appropriate page content
let pageContent model =
match model.CurrentPage with
| NotFound -> Html.text "Page not found"
| TodoList -> TodoList.view ()
In the view function, replace the call to todoList with a call to pageContent
let view model dispatch =
Html.section [
...
pageContent model
...
]
let view model dispatch =
Html.section [
...
- todoList view model
+ pageContent model
...
]
9. Add the router to the view
Wrap the content of the view method in a React.Router element's router.children property, and add a router.onUrlChanged property to dispatch the urlChanged message
let view model dispatch =
React.router [
router.onUrlChanged ( PageChanged>>dispatch )
router.children [
Html.section [
...
]
]
]
let view (model: Model) (dispatch: Msg -> unit) =
+ React.router [
+ router.onUrlChanged ( PageChanged>>dispatch )
+ router.children [
Html.section [
...
]
+ ]
+ ]
10. Try it out
The routing should work now. Try navigating to localhost:8080; you should see a page with "Page not Found". If you go to localhost:8080/#/todo, you should see the todo app.
# sign
You might be surprised to see the hash sign as part of the URL. It enables React to react to URL changes without a full page refresh. There are ways to omit this, but getting this to work properly is outside of the scope of this recipe.