Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions docs/recipes/ui/routing-with-elmish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
# How do I create multi-page applications with routing and the useElmish hook?

[UseElmish](https://zaid-ajaj.github.io/Feliz/#/Hooks/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

```bash
dotnet paket add Feliz.Router -p Client
```

Install Feliz.UseElmish in the Client project

```bash
cd src/Client
dotnet femto install Feliz.UseElmish
```

Open the router in the client project

```fsharp title="Index.fs"
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`.

```fsharp title="TodoList.fs"
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

```fsharp title="TodoList.fs"
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`

=== "Code"
```fsharp title="TodoList.fs"
let view model dispatch =
let model, dispatch = React.useElmish (init, update, [||])
...
```

=== "Diff"
```.diff title="TodoList.fs"
-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

=== "Code"
```fsharp title="Index.fs"
[<ReactComponent>]
let view () =
...
```
=== "Diff"
```.diff title="Index.fs"
+ [<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

```fsharp title="Index.fs"
type Page =
| TodoList
| NotFound

type Model =
{ CurrentPage: Page }
```
## 6. Initializing the application

Create a function that initializes the app based on an url

```fsharp title="Index.fs"
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.

```fsharp title="Index.fs"
let init () = Router.currentUrl () |> initFromUrl
```
## 7. Updating the Page

Add a `Msg` type, with an PageChanged case

```fsharp title="Index.fs"
type Msg =
| PageChanged of string list
```
Add an `update` function, that reinitializes the app based on an URL

```fsharp title="Index.fs"
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

```fsharp title="Index.fs"
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`

=== "Code"
```fsharp title="Index.fs"
let view model dispatch =
Html.section [
...
pageContent model
...
]
```
=== "Diff"
```diff title="Index.fs"
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

=== "Code"
```fsharp title="Index.fs"
let view model dispatch =
React.router [
router.onUrlChanged ( PageChanged>>dispatch )
router.children [
Html.section [
...
]
]
]
```
=== "Diff"
```diff title="Index.fs"
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](http://localhost:8080/); you should see a page with "Page not Found". If you go to [localhost:8080/#/todo](http://localhost:8080/#/todo), you should see the todo app.

!!! info "# 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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ nav:
- Migrate from a CDN stylesheet to an NPM package: "recipes/ui/cdn-to-npm.md"
- Add routing with state shared between pages: "recipes/ui/add-routing.md"
- Add routing with separate models per page: "recipes/ui/add-routing-with-separate-models.md"
- Add Routing with UseElmish: "recipes/ui/routing-with-elmish.md"
- Storage:
- Quickly add a database: "recipes/storage/use-litedb.md"
- JavaScript:
Expand Down