Skip to content

Commit 79add82

Browse files
authored
Merge pull request #330 from Larocceau/routing-separate-models
Routing separate models
2 parents 5ecdb34 + 6fe3aef commit 79add82

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
# How do I add routing to a SAFE app with separate model for every page?
2+
3+
*Written for SAFE template version 4.2.0*
4+
5+
If your application has multiple separate components, there is no need to have one big, complex model that manages all the state for all components. In this recipe we separate the information of the todo list out of the main `Model`, and give the todo list application its own route. We also add a "Page not found" page.
6+
7+
## 1. Adding the Feliz router
8+
9+
Install Feliz.Router in the client project
10+
11+
```bash
12+
dotnet paket add Feliz.Router -p Client
13+
```
14+
15+
To include the router in the Client, open `Feliz.Router` at the top of Index.fs
16+
17+
```fsharp title="Index.fs"
18+
open Feliz.Router
19+
```
20+
21+
## 2. Creating a module for the Todo list
22+
23+
Move the following functions and types to a new `TodoList` Module in a file `TodoList.fs`:
24+
25+
* Model
26+
* Msg
27+
* todosApi
28+
* init
29+
* update
30+
* toDoAction
31+
* todoList; rename this to view and remove the `private` access modifier
32+
33+
also open `Shared`, `Fable.Remoting.Client`, `Elmish` and `Feliz`
34+
35+
```fsharp title="TodoList.fs"
36+
module TodoList
37+
38+
open Shared
39+
open Fable.Remoting.Client
40+
open Elmish
41+
open Feliz
42+
43+
type Model = { Todos: Todo list; Input: string }
44+
45+
type Msg =
46+
| GotTodos of Todo list
47+
| SetInput of string
48+
| AddTodo
49+
| AddedTodo of Todo
50+
51+
let todosApi =
52+
Remoting.createApi ()
53+
|> Remoting.withRouteBuilder Route.builder
54+
|> Remoting.buildProxy<ITodosApi>
55+
56+
let init () =
57+
let model = { Todos = []; Input = "" }
58+
let cmd = Cmd.OfAsync.perform todosApi.getTodos () GotTodos
59+
model, cmd
60+
61+
let update msg model =
62+
match msg with
63+
| GotTodos todos -> { model with Todos = todos }, Cmd.none
64+
| SetInput value -> { model with Input = value }, Cmd.none
65+
| AddTodo ->
66+
let todo = Todo.create model.Input
67+
68+
let cmd = Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo
69+
70+
{ model with Input = "" }, cmd
71+
| AddedTodo todo ->
72+
{
73+
model with
74+
Todos = model.Todos @ [ todo ]
75+
},
76+
Cmd.none
77+
78+
let private todoAction model dispatch =
79+
Html.div [
80+
prop.className "flex flex-col sm:flex-row mt-4 gap-4"
81+
prop.children [
82+
Html.input [
83+
prop.className
84+
"shadow appearance-none border rounded w-full py-2 px-3 outline-none focus:ring-2 ring-teal-300 text-grey-darker"
85+
prop.value model.Input
86+
prop.placeholder "What needs to be done?"
87+
prop.autoFocus true
88+
prop.onChange (SetInput >> dispatch)
89+
prop.onKeyPress (fun ev ->
90+
if ev.key = "Enter" then
91+
dispatch AddTodo)
92+
]
93+
Html.button [
94+
prop.className
95+
"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"
96+
prop.disabled (Todo.isValid model.Input |> not)
97+
prop.onClick (fun _ -> dispatch AddTodo)
98+
prop.text "Add"
99+
]
100+
]
101+
]
102+
103+
let view model dispatch =
104+
Html.div [
105+
prop.className "bg-white/80 rounded-md shadow-md p-4 w-5/6 lg:w-3/4 lg:max-w-2xl"
106+
prop.children [
107+
Html.ol [
108+
prop.className "list-decimal ml-6"
109+
prop.children [
110+
for todo in model.Todos do
111+
Html.li [ prop.className "my-1"; prop.text todo.Description ]
112+
]
113+
]
114+
115+
todoAction model dispatch
116+
]
117+
]
118+
```
119+
120+
## 3. Adding a new Model to the Index page
121+
122+
Create a new Model in the `Index` module, to keep track of the open page
123+
124+
```fsharp title="Index.fs"
125+
type Page =
126+
| TodoList of TodoList.Model
127+
| NotFound
128+
129+
type Model = { CurrentPage: Page }
130+
```
131+
132+
## 4. Updating the TodoList model
133+
134+
Add a `Msg` type with a case of `TodoList.Msg`
135+
136+
```fsharp title="Index.fs"
137+
type Msg =
138+
| TodoListMsg of TodoList.Msg
139+
```
140+
141+
Create an `update` function (we moved the original one to `TodoList`). Handle the `TodoListMsg` by updating the `TodoList` Model. Wrap the command returned by the `update` of the todo list in a `TodoListMsg` before returning it. We expand this function later with other cases that deal with navigation.
142+
143+
```fsharp title="Index.fs"
144+
let update message model =
145+
match model.CurrentPage, message with
146+
| TodoList todoList, TodoListMsg todoListMessage ->
147+
let newTodoListModel, todoCommand = TodoList.update todoListMessage todoList
148+
149+
let model = {
150+
model with
151+
CurrentPage = TodoList newTodoListModel
152+
}
153+
154+
model, todoCommand |> Cmd.map TodoListMsg
155+
```
156+
157+
## 5. Initializing from URL
158+
159+
Create a function `initFromUrl`; initialize the `TodoList` app when given the URL of the todo list app. Also return the command that TodoList's `init` may return, wrapped in a `TodoListMsg`
160+
161+
```fsharp title="Index.fs"
162+
let initFromUrl url =
163+
match url with
164+
| [ "todo" ] ->
165+
let todoListModel, todoListMsg = TodoList.init ()
166+
let model = { CurrentPage = TodoList todoListModel }
167+
168+
model, todoListMsg |> Cmd.map TodoListMsg
169+
```
170+
171+
Add a wildcard, so any URLs that are not registered display the "not found" page
172+
173+
=== "Code"
174+
```fsharp title="Index.fs"
175+
let initFromUrl url =
176+
match url with
177+
...
178+
| _ -> { CurrentPage = NotFound }, Cmd.none
179+
```
180+
=== "Diff"
181+
```.diff title="Index.fs"
182+
let initFromUrl url =
183+
match url with
184+
...
185+
+ | _ -> { CurrentPage = NotFound }, Cmd.none
186+
```
187+
188+
## 6. Elmish initialization
189+
190+
Add an `init` function to `Index`; return the current page based on `Router.currentUrl`
191+
192+
```fsharp title="Index.fs"
193+
let init () =
194+
Router.currentUrl ()
195+
|> initFromUrl
196+
```
197+
198+
## 7. Handling URL Changes
199+
200+
Add an `UrlChanged` case of `string list` to the `Msg` type
201+
202+
=== "Code"
203+
```fsharp title="Index.fs"
204+
type Msg =
205+
...
206+
| UrlChanged of string list
207+
```
208+
=== "Diff"
209+
```diff title="Index.fs"
210+
type Msg =
211+
...
212+
+ | UrlChanged of string list
213+
```
214+
215+
Handle the case in the `update` function by calling `initFromUrl`
216+
217+
=== "Code"
218+
```fsharp title="Index.fs"
219+
let update message model =
220+
...
221+
match model.CurrentPage, message with
222+
| _, UrlChanged url -> initFromUrl url
223+
```
224+
=== "Diff"
225+
```diff title="Index.fs"
226+
let update message model =
227+
...
228+
+ match model.CurrentPage, message with
229+
+ | _, UrlChanged url -> initFromUrl url
230+
```
231+
232+
## 8. Catching all cases in the update function
233+
234+
Complete the pattern match in the `update` function, adding a case with a wildcard for both `message` and `model`. Return the model, and no command
235+
236+
=== "Code"
237+
```fsharp title="Index.fs"
238+
let update message model =
239+
...
240+
| _, _ -> model, Cmd.none
241+
```
242+
=== "Diff"
243+
```.diff title="Index.fs"
244+
let update message model =
245+
...
246+
+ | _, _ -> model, Cmd.none
247+
```
248+
## 9. Rendering pages
249+
250+
Add a function pageContent to the `Index` module. If the CurrentPage is of `TodoList`, render the todo list using `TodoList.view`; in order to dispatch a `TodoList.Msg`, it needs to be wrapped in a `TodoListMsg`.
251+
252+
For the `NotFound` page, return a "Page not found" box
253+
254+
```fsharp title="Index.fs"
255+
let pageContent model dispatch =
256+
match model.CurrentPage with
257+
| TodoList todoListModel -> TodoList.view todoListModel (TodoListMsg >> dispatch)
258+
| NotFound -> Html.text "Page not found"
259+
```
260+
261+
In the view function, replace the call to `todoList` with a call to `pageContent`
262+
263+
=== "Code"
264+
```fsharp title="Index.fs"
265+
let view model dispatch =
266+
...
267+
pageContent model dispatch
268+
...
269+
```
270+
=== "Diff"
271+
```.diff title="Index.fs"
272+
let view model dispatch =
273+
...
274+
- todoList model dispatch
275+
+ pageContent model dispatch
276+
...
277+
```
278+
279+
## 10. Adding the React router to the view
280+
281+
Wrap the content of the view function in a `router.children` property of a `React.router`. Also add an `onUrlChanged` property, that dispatches the 'UrlChanged' message.
282+
283+
=== "Code"
284+
```fsharp title="Index.fs"
285+
let view model dispatch =
286+
React.router [
287+
router.onUrlChanged (UrlChanged >> dispatch)
288+
router.children [
289+
Html.section [
290+
...
291+
]
292+
]
293+
]
294+
```
295+
=== "Diff"
296+
```.diff title="Index.fs"
297+
let view model dispatch =
298+
+ React.router [
299+
+ router.onUrlChanged (UrlChanged >> dispatch)
300+
+ router.children [
301+
Html.section [
302+
...
303+
]
304+
+ ]
305+
+ ]
306+
```
307+
308+
## 11. Running the app
309+
310+
The routing should work now. Try navigating to [localhost:8080](http://localhost:8080/todo); 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.
311+
312+
!!! info "# sign"
313+
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.
314+
There are ways to omit this, but getting this to work properly is outside of the scope of this recipe.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ nav:
8585
- Add FontAwesome support: "recipes/ui/add-fontawesome.md"
8686
- Migrate from a CDN stylesheet to an NPM package: "recipes/ui/cdn-to-npm.md"
8787
- Add routing with state shared between pages: "recipes/ui/add-routing.md"
88+
- Add routing with separate models per page: "recipes/ui/add-routing-with-separate-models.md"
8889
- Storage:
8990
- Quickly add a database: "recipes/storage/use-litedb.md"
9091
- JavaScript:

0 commit comments

Comments
 (0)