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 Page
s and each Page
can have many Comment
s.
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 Page
s and each one can have Comment
s.
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 Var
s 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.
ListModel
s 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!