Mar 25th, 2016 - written by Kimserey with .
Last week I needed to make a two way binding for a record with nested lists. More precisely, I needed to observe all changes on this record. This changes included normal members but also lists and I needed to observe changes like adding and removing items.
It took me a week to come out with a solution where I had to iterate multiple times to get to it. I started with something which was far from ideal then had a conversation on WebSharper forum with @tarmil_ and @inchester23 and came out with other better solutions.
The process was as beneficial as the solution is. So today I will take another approach for this blog post and instead of presenting the final solution directly, I will walk you through all the steps I took to finally come up with the solution. And as usual, the code is available on GitHub.
Here are the steps:
The record for which I wanted to observe every members was the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Book = {
Title: string
Pages: Page list
}
and Page = {
Number: int
Content: string
Comments: Comment list
}
and Comment = {
Number: int
Content: string
}
A Book can have many Pages and each Page can have many Comments.
It’s quite trivial to observe variables with Var and View.
If you are not familiar with UI.Next, have a look at my previous blog post on how to make a SPA with WebSharper.
But how would you observe members of a record?
The first solution which came out was to make a full mutable record.
Based on Book, we create a ReactiveBook with all the members as Var<_>.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type Book = {
Title: string
Pages: Page list
}
and Page = {
Number: int
Content: string
Comments: Comment list
}
and Comment = {
Number: int
Content: string
}
type ReactiveBook = {
Title: Var<string>
Pages: Var<ReactivePage list>
}
and ReactivePage = {
Number: Var<int>
Content: Var<string>
Comments: Var<ReactiveComment list>
}
and ReactiveComment = {
Number: Var<int>
Content: Var<string>
}
By doing this, we can observe every member of the ReactiveBook.
To be able to react to any change, we need to construct a view of this record.
We do that by combining all the views of the member and make one single view for the ReactiveBook.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let (<*>) f x = View.Apply f x
type ReactiveComment with
static member View comment: View<Comment> =
View.Const (fun n c ->
{ Number = n
Content = c })
<*> comment.Number.View
<*> comment.Content.View
type ReactivePage with
static member View (page: ReactivePage): View<Page> =
View.Const (fun n c com->
{ Number = n
Content = c
Comments = com |> Seq.toList })
<*> page.Number.View
<*> page.Content.View
<*> (page.Comments.View
|> View.Map (fun comments ->
comments
|> List.map ReactiveComment.View
|> View.Sequence)
|> View.Join)
type ReactiveBook with
static member View book: View<Book> =
View.Const (fun t p ->
{ Title = t
Pages = p |> Seq.toList })
<*> book.Title.View
<*> (book.Pages.View
|> View.Map (fun pages ->
pages
|> List.map ReactivePage.View
|> View.Sequence)
|> View.Join)
This way we can map over a ReactiveBook.View or use Doc.BindView to render it.
1
2
3
4
5
6
7
let rvBook =
Var.Create { Title = Var.Create "New book"
Pages = Var.Create [] }
rvBook
|> ReactiveBook.View
|> Doc.BindView Book.Render
And like that when we change anything in rvBook, it will be reflected in the doc.
What is wrong with that?
Although it works, the error here is that I transformed a record to a totally mutable record and
passing around Var is not the recommended approach. Also creating a duplicate record feels wrong.
What I wanted from the beginning was to be able to create a Var.Create Book and just use that directly.
I didn’t want to have to bother with a ReactiveBook.
So I requested for some help and @tarmil_ pointed to me that there was a set functions exactly for my needs called Lenses.
This is exactly the kind of situation you would use lensing for. The type IRef<’T> is an abstract class that is implemented by Var<’T>, but also returned by the Lens method which creates a bidirectional binding into another IRef<’T>
So let’s take a look at Lenses.
Let’s review how we make a reactive variable with Var and View.
1
2
3
4
5
6
let txt =
Var.Create ""
let doc =
txt.View
|> Doc.BindView (fun t -> text t)
We create a txt reactive variable and bind it to a doc.
We can set the txt by using Var.Set.
1
Var.Set txt "new text!"
By doing that, the changes are directly propagated to the doc. If we want to react to changes in records, we can do the same:
1
2
3
4
type MyRecord = { Content: string }
let r =
Var.Create { Content = "" }
Because records are immutable, if you want to react to changes in the Content member, you need to recreate the whole record.
1
Var.Set r { Content = "Hello world" }
But if you remember, our Book can have multiple Pages and each one can have Comments.
Imagine what we would need to do if we wanted to change the content of a Comment.
Lucky us, we have Lens.
Lenses in WebSharper allow us to target a particular member and extract a IRef<_> out of it.
IRef<_> is the interface implemented by Var and we can use it with a set of function to create inputs like Doc.Input.
The signature of Lens on Var is:
1
IRef<'a>.Lens :: ('a -> 'b) -> ('a -> 'b -> 'a) -> IRef<'b>
The first function 'a -> 'b is used to select the member on which we want to lens.
And the second function 'a -> 'b -> 'a is used to update the current record of type 'a with the value set of type 'b.
The Lens returns a reactive variable of 'b which is the type of the member we lens into.
Since a IRef<_> is returned, we can lens another level and this will also return another IRef<_> and we can continue indefinitely like that.
So if we wanted to have a reactive variable on Comment.Content, from the Book I can lens into a particular Page then lens into a particular Comment and get out a IRef<string>.
### Change our model
Now we can throw away the ReactiveBook and build some Lenses helpers using the Lens on Book!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
type Book = {
Title: string
Pages: Page list
} with
static member LensTitle (v: IRef<Book>) : IRef<string> =
v.Lens
(fun b -> b.Title)
(fun b t ->
{ b with Title = t })
static member LensPages (v: IRef<Book>) : IRef<Page list> =
v.Lens
(fun b -> b.Pages)
(fun b p ->
{ b with Pages = p })
static member LensPage n (v: IRef<Book>) : IRef<Page> =
v.Lens
(fun b ->
b.Pages
|> List.find (fun p -> p.Number = n))
(fun b p ->
{ b with
Pages =
b.Pages
|> List.map (fun p' -> if p'.Number = n then p else p') })
and Page = {
Number: int
Content: string
Comments: Comment list
} with
static member LensNumber (v: IRef<Page>) : IRef<int> =
v.Lens
(fun c -> c.Number)
(fun c n ->
{ c with Number = n })
static member LensContent (v: IRef<Page>) : IRef<string> =
v.Lens
(fun c -> c.Content)
(fun c cont ->
{ c with Content = cont })
static member LensComments (v: IRef<Page>) : IRef<Comment list> =
v.Lens
(fun c -> c.Comments)
(fun p c ->
{ p with Comments = c })
static member LensComment n (v: IRef<Page>) : IRef<Comment> =
v.Lens
(fun p ->
p.Comments
|> List.find (fun p -> p.Number = n))
(fun c com ->
{ c with
Comments =
c.Comments
|> List.map (fun c' -> if c'.Number = n then com else c') })
and Comment = {
Number: int
Content: string
} with
static member LensNumber (v: IRef<Comment>) : IRef<int> =
v.Lens
(fun c -> c.Number)
(fun c n -> { c with Number = n })
static member LensContent (v: IRef<Comment>) : IRef<string> =
v.Lens
(fun c -> c.Content)
(fun c cont -> { c with Content = cont })
And we can also throw away all the methods to create a view. Since we only deal with book we now have successfuly reduced the number Vars to only one and also remove the “reactive” copy of Book.
We started with a copy of the original record with all the members being Var and we now end up with only one Var.
We eliminated a record full of Vars!
We now have a bidirectional binding with our Book type. But we are dealing with list and when anything is changed, we recreate the whole Book.
@inchester23 pointed to me that to optimise that I could make use of ListModel.
You could define a ListModel of books then lens all the way down to the content of a comment. So using an immutable model your code might look like the following: http://try.websharper.com/snippet/qwe2/00007D. But there is an issue here: since our model is immutable, we have to copy and update the whole thing even if we just change one comment. So, while this clean and pretty, if you have a huge amounts of books and pages and comments this will get pretty slow. What i would do in that case is to define pages and comments to be ListModel<int, Page> and ListModel<int, Comment>
ListModel are used when we deal with reactive list. Instead of using Var<string list>, we can use ListModel<string, string>.
To create a ListModel, we use ListModel.Create which has a type:
1
ListModel.Create :: 'a -> 'key -> seq<'a> -> ListModel<'key, 'a>
The first type represents the key and the second represents the model.
The key is used to target a particular instance in the list.
ListModels are cool because they offer a set of helpful functions like Add, Remove, RemoveBy and most importantly when observing them, we can use MapSeqCachedBy or Doc.BindSeqCached which are optimised to do some clever caching for the elements which have not changed yet.
Also, ListModel has a special lens LensInto which allows us to get our a IRef<_> from a member of an element of the list.
With ListModel we endup with a even simpler model.
Let’s change the list to ListModel.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Book = {
Title: string
Pages: ListModel<int, Page>
} with
static member LensTitle (v: IRef<Book>) : IRef<string> =
v.Lens
(fun b -> b.Title)
(fun b t -> { b with Title = t })
and Page = {
Number: int
Content: string
Comments: ListModel<int, Comment>
} with
static member LensIntoContent key (pages: ListModel<int, Page>) : IRef<string> =
pages.LensInto
(fun p -> p.Content)
(fun p c -> { p with Content = c })
key
and Comment = {
Number: int
Content: string
} with
static member LensIntoContent key (comments: ListModel<int, Comment>) : IRef<string> =
comments.LensInto
(fun c -> c.Content)
(fun c c' -> { c with Content = c' })
key
With that we eliminated the extra Lens functions that we needed for our list types because we can directly use the Lens and LensInto functions exposed by ListModel. On top of that we can use Doc.BindSeqCached when rendering the list and get better performance.
Wonderful! We now have a model that can be observed on any members including the lists.
I hope this post showed the importance of getting people to review your code. When I started programming, I used to be stressed over people reviewing my code. But after I passed that mental barrier, I rapidly understood that reviews from trusted entities were extremely beneficial to write better software but also for me to improve.
We started with an idea and a bad implementation. After few rounds of conversation with the guys working on WebSharper, we ended up with a very nice solution and we ended up with understanding much better some functionalities of WebSharper.
We now know that we should restrict the number of Var that we use. Then when dealing with list we can use ListModel. Finally we know that we can use Lenses to observe members of records. All this thoughts would not have came up if I would have stopped at the first solution. Hope you enjoyed this post, if you have any comments, leave it here or hit me on Twitter @Kimserey_Lam. Thanks for reading!