Var, View, Lens, ListModel in UI.Next

Var, View, Lens, ListModel in UI.Next

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 Loïc and István and came out with other better solutions.

The process was as beneficial as the solution. 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:

  1. The wrong way - a mutable record - link to code
  2. The right way - lensing into members - link to code
  3. The optimised way - optimising with ListModel - link to code

The record for which I wanted to observe every members was the following:

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.

The wrong way - a mutable record

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<_>.

    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.

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.

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 Loïc 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>

Loïc

So let’s take a look at Lenses.

The right way - lensing into members

What are lenses?

Let’s review how we make a reactive variable with Var and View.

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.

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:

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.

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:

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!

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!

The optimised way - optimising with ListModel

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.

István 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>

István

What are ListModel?

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:

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.

Change our model

With ListModel we endup with a even simpler model. Let’s change the list to ListModel.

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.

Conclusion

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!

Comments

  1. Very interesting!

    We came up with pretty much the same approach, namely combining lenses with reactive variables, a few months ago and have written a few tiny JavaScript libraries to make that possible. Using those libs we have written a non-trivial SPA, a custom CMS, that is already in production.

    The combination of lenses and reactive variables, we are calling those "atoms", really works absolutely beautifully! It is just amazing how simple everything becomes and that you can just plug-and-play components.

    I'm currently in the process of documenting the approach better. You can find the documentation and libraries from the calmm-js GitHub project.

    ReplyDelete

Post a Comment

Popular posts from this blog

A complete SignalR with ASP Net Core example with WSS, Authentication, Nginx

Verify dotnet SDK and runtime version installed

SDK-Style project and project.assets.json