davedawkins/Sutil

Preferred way to represent "not yet known" value

sajagi opened this issue · 6 comments

Hi,

What is the canonical way to represent "not yet known" value? For example, result of an async expression?

The simplest way (achievable now) is to simply use Store<T option>.

Stores and their derivations are implementations of IObservable<T>, which do not guarantee that values are always present (that is, the observable is not "behavior subject", as described here. This would most likely work with current functions (e.g. Bind.el), however, there is no way to represent "loading" state (or is there?). A possibility would be to add a new function overload with loader element to be displayed when value is not available yet.

What are your thoughts on this?

Hi,
I use Bind.promise for this. Here's one example of usage:

https://sutil.dev/#examples-await-blocks

That's interesting to read about BehaviorSubject. Store does have an initial value, but you're right that as soon as you start projecting and filtering, you end up with IObservable. (Store does guarantee to send the initial value out to any new subscribers though)

Here are the Bind.promise functions:

    static member promises (items : IObservable<JS.Promise<'T>>, view : 'T  -> SutilElement, waiting: SutilElement, error : Exception -> SutilElement)=
        Bind.el( items, fun p -> Bind.promise(p, view, waiting, error) )

    static member promise (p : JS.Promise<'T>, view : 'T  -> SutilElement, waiting: SutilElement, error : Exception -> SutilElement)=
        Bind.el(  p.ToObservable(), fun state ->
            match state with
            | PromiseState.Waiting -> waiting
            | PromiseState.Error x -> error x
            | PromiseState.Result r ->  view r
        )

    static member promise (p : JS.Promise<'T>, view : 'T  -> SutilElement) =
        let w = el "div" [ CoreElements.class' "promise-waiting"; text "waiting..."]
        let e (x : Exception) = el "div" [ CoreElements.class' "promise-error"; text x.Message ]
        Bind.promise(p, view, w, e )

Thanks!

I see you use a similar approach to the 'T option one (with the ability to handle errors). I tweak my store initialization so I can directly modify the model in the store (plus wrapping it in Some). Error handing is usually done upstream (or I use Result):

module Store = 
   let makeAsyncOption (x: Async<'T>) : IStore<'T option> =
        let store = Store.make None
        async {
            let! result = x
            result |> Some |> Store.set store
        } |> Async.StartImmediate
        store

Suggested variant would be to use a custom store-like class implementing IObservable<'T>, that would not have an initial value. Set would work the same (and would initialize the store), Modify would trigger an error when called in uninitialized state, Value would become TryGetValue. The upside is that the consumers would have no notion of its "lazy" flavor, be it promise, async or something completely else. I wonder what the repercussions would be. It probably needs to be put to test ;)

I'm open to this, but I need to understand why it would be desirable to have this extra code to maintain when we can already implement Store<'T> with 'T = Option<'U>
I think it's because I haven't understood your use case properly, and why using Option<> isn't expressive enough.

It is more about what is the behavior of Bind.el and similar methods / functions for IObservable<> which does not provide a value right away. Instances of IObservable do not have to be necessarily derived from Store (for any reason) and having no initial value is not forbidden by the interface contract. Currently, any binding to empty IObservable becomes a SideEffect, which is in practice similar to Html.none (?).

One could take advantage of this behavior and use it for "value not yet available". The extensions which would allow more user-friendly API (creating stores without initial value, having Bind.el with alternative "loader" element, etc.) do not even have to be inside the core library.

On the other hand, this means any current and future usages of IObservable must never rely on a value present (for example, checking the subscriber has been called by the time the call of Subscribe is finished).

edit: typos

I see. It's not so much about the way Store behaves, but consumers of IObservable. It's true that in Sutil, I know that when I'm handed an IObservable, and subscribe to it, that I will immediately receive the initial value (because of the way Store works, which I modelled on Svelte's stores/cells).

Would it be useful then to have overloads of Bind.el (et al) like the following?

// Current signature (or at least, very close to it
Bind.el( data : IObservable<'T>, view: 'T -> SutilElement )   

// Using Option
//  view None will be shown until a value arrives
Bind.el( data : IObservable<'T>, view: 'T option -> SutilElement ) 

// Without Option, using init/view pair
//  init() will be shown value arrives
Bind.el( data : IObservable<'T>, init: unit -> SutilElement, view: 'T -> SutilElement ) 

Option feels more canonical, but I don't like that we have to wrap every value in Some just to handle an initial case.

Regardless of that small point, am I addressing the issue you're raising?

Cheers

Yes, it's exactly what I had in mind. Sorry it took me several posts to make myself clear :)

Unless you have some prior objections to the suggested behavior, I'll test it in one of my projects to see how it performs in real scenarios. Then you should be able to write something like:

let store : IObservable<_> = getDataAsync() |> DelayedStore.makeAsync

// ...

Bind.el(store, (fun () -> Html.text "Loading...."), (fun data -> Html.text $"Data received: {data}"))