Optimistic UIs in LiveView

The topic of optimistic UIs in LiveView came up already e.g. How do you achieve Optimistic UI in Phoenix with LiveView? by @pillaiindu
but I wanted to show my solution.

LiveView team claims that LV is not suitable for optimistic UIs but I feel that there is a conceptually easy fix for that I present in this repo: https://github.com/tomekowal/optimistic_counter

I described my solution in the https://github.com/tomekowal/optimistic_counter/blob/master/README.md

Let me know what you think about it!

5 Likes

One recommendation:

Try out whatever you are trying to do in a commuting trip, like by train, bus or even as passenger in a car and then see if Live View fits the use case, because it will not if used to every DOM change, it will be sluggish.

In my product I am using Live View but keeping it to places where business logic needs to be applied to, anything else is not the best fit for Live View. User interactions with the DOM are best left to be handled in the client side.

1 Like

I think it is a great solution. I think that (simplified or full) vector-clocks might provide for a good way to resolve ā€˜staleā€™ front-ends.

One problem this approach (currently) has of course, is that the ā€˜optimisticā€™ handler needs to be written in plain JavaScript. This might change once Lumen can be used easily, or maybe one could purpose ElixirScript for it. Then again, the parts of a UI that need to be optimistic are probably limited, so it might still be possible to do the bulk in Elixir-on-the-server anyway.

2 Likes

Yep, that is my concern too.
a) I need to write JS
b) it works only for state + event -> new_state transformations (if there is something impure like DB call in handle_event then of course JS part canā€™t do it

Implementing the logic twice in two languages is error-prone. Maybe some kind of macro that brings the JS and Elixir implementations close to each other can at least make it easier to edit both at the same time.

On the other hand, multiplayer games are perfect examples of doing exactly that. The same logic is repeated in client and server. Server is the source of truth.

2 Likes

For such cases Iā€™d currently rather look into node ssr for client and server side rendering of those components and try to couple it with a channels solution, which might work similar to liveview in the process spawning / callbacks, but skips all the complexity of the templating / template diffing.

1 Like

Thanks for your input! If I understand you correctly, you propose to write the core event handling logic in JS so that you can reuse it both on the server and client and then use channels to synchronize the states.

It might work but LiveView gives me much more. The server LiveView process is a single source of truth and I loose that if I use plain channels. The state synchronization is very hard to do efficiently so I really appreciate how great LiveView is in reducing the payload size between client and server.

LiveView also gives me entire lifecycle in case of dropped connections. Doing all that using channels seems like a big hurdle that would end up in a half reimplementation of LiveView.

I might be greatly underestimating the task of adding event counters to client and server side of LiveView but after we have that, optimistic UI algorithms boils down to:

if server_clock >= client_clock do
  patch the dom as usual because server is always right
else
  don't patch the dom because user clicked/typed a bunch of things so there are still events coming from the server
end

It might need a separate ā€œclient modification timestampā€ for each ā€œgapā€ in the template and some JS functions to set that stamp when you modify those gaps on the client side. But it still seems like much less work than building custom solutions on bare channels.

1 Like

Youā€™ll lose that as soon as your client becomes stateful for the state you also deal with on the server.

This is exactly the reason, why liveview doesnā€™t synchronize state at all. Thereā€™s state on the server and thereā€™s markup derived from this state. The markup is sent to the client and replaces existing markup. Thereā€™s the fancy markup diffing, where the client does indeed hold some amount of state (the static parts), but everything on the client is replaced in the case of a reconnect.

This basically boils down to an CRDT implementation. Thereā€™s e.g. automerge for JS, which implements crdts for json data and also has existing tooling for synchronising data via network connections. The ā€œproblemā€ for an elixir based solution is that you need node on the server or you need to re-implement the automerge crdt protocol in elixir. Iā€™ve experimented with automerge from client to client proxied through phoenix channels in the past and itā€™s interesting tech, but also complex.

2 Likes

FWIW, LiveView already does this internally. For example, imagine you have three buttons on the page. Once you click them, they change their text to ā€œProcessingā€¦ā€. Processing each button takes 1 second. If you click all 3 of them one right after the other, the first response will come in t=1, then t=2, then t=3.

if LiveView did not track the events, once t=1 arrived, all buttons would revert to their original text, but it doesnā€™t.

So maybe we can generalize this a bit so people can also leverage this information in hooks or similar, but the whole infrastructure is already there. This may be possible, I think it is a great idea!

Not really. The goal with optimistic UI is to change the client as a way to optimistically guess what the server will return. There is no state is really. The changes you do on the client will be discarded as soon as the ack is received.

4 Likes

Seems like I misunderstood the intro. Seems like counters as examples nowadays automatically mean distributed state to me :sweat_smile:

3 Likes

Haha :smiley: I might not have been clear enough :slight_smile:

Thanks for validation! Iā€™ll check if I am able to dig in into infrastructure enough to propose something meaningful.

I currently imagine the API like this:

  • tag parts of the template as ā€œoptimisticā€ to prevent updates with stale values from the server
  • allow hooking into JS LiveView events when they fire to do changes to those parts in the template (in the repo I am using phx-hook but I would like to tie optimistic updates to JS LV events instead of DOM lifecycle events)
  • some functions to modify the DOM but with setting the time of modification to current event time
1 Like

I have talked to Chris about an API like this:

  1. In a hook, you will be able to call this.syncPushEvent(...)

  2. this.syncPushEvent(...) will block all over-the-wire updates to the Hook, until an ack to the syncPushEvent is received. Custom JS code in the hook still runs though

  3. Once a reply for the push event is received, we call the hook updated callback, so you can apply the server state to the hook contents

It is basically the mechanism that we use for optimistic UIs in buttons and forms, except we are making it available for hooks too. WDYT?

2 Likes

Some questions to make sure I understand the solution proposed here. Letā€™s work on the counter example to have something concrete.
We have two DOM elements: the button that emits click event and the counter.

Where do I use the this.syncPushEvent? Do I attach it to onClick of that button inside mounted section of that hook? Will that prevent sending regular event?
Where do I put the code that updates the counter? in the updated section of the button hook? How do we know we should not update the counter in the meantime?

I think that the solution LiveView currently uses for buttons can work only because the event and state update are for the same DOM element. In case of optimistic UI, we should be able to change multiple parts of the page with a single event.

I was thinking about a different solution using client and server clocks.

  1. JS part has the vector clock client.clock. The server has its own, letā€™s call it server.clock
  2. Server increments its own clock every time it sends new state (e.g. an event may originate in the server from PubSub). That part is really important and AFAIU is not part of the current solution with view.ref.
  3. I am marking UI elements that I think should be lazily updated e.g: <p phx-update="optimistic"><%= @counter %></p>.
  4. I am attaching hook to emitted LiveView event. E.g. <button phx-click="inc" phx-hook="Button">+</button>
  5. In the hook, there needs to be a section for running this custom code right after event gets sent to the server:
Hooks.Button = {
  onEvent("inc") { //should be called right after "inc" event is sent
    counter = getElementById("counter")
    mark_updated(counter) //add something like `phx-clock="#{client.clock}"` to the counter
    ... //increment the counter to achieve instant update
  }
}
  1. When state update comes from the server, it has the server.clock.
if server.clock > client.clock, do: client.clock = server.clock

// when updating elements
if element.phx-update=="lazy" && server.clock >= element.phx-clock do
  normally_patch_the_DOM()
else
  ignore()
end

I believe that solution is much more general because it takes into consideration both events started by the browser and server.
It canā€™t replace the current solution for buttons with view.ref because you need to precisely know which client event that button is handling.

Also without separate clocks, you canā€™t handle optimistic updates originating on the server.

Does that make sense?

The machinery might seem complicated but I wanted it to work on examples like this:

  1. User quickly increments counter twice. JS optimistically sets it two. Counter = 2 Client clock = 2.
  2. Server processes first event. Sends Counter = 1, Server clock = 1. Since Server clock is lower than client clock, it doesnā€™t do the update (so the counter doesnā€™t go back)
  3. Server processes the second event. Sends Counter = 2 Server clock = 2. Now the counters are the same so we replace the number 2 with number 2 (everything is fine)

This works even if server-side increments the counter a bunch of times.

The clock example will definitely work with what we have in mind. The trick though is that, because you have to update the client, you wonā€™t be using phx-click when you first click on the button, but rather a hook, so you can update the client and then push the event.

2 Likes

Yes, We would need to make sure that the hook is closely tied to the event for two reasons:

  • we need client.clock tied to sending event to mark the DOM
  • it would be easier to work with if we use the same event name in JS and Elixir (both implementations should do similar thing, but the one from server should eventually overwrite the one client)

So I would even send the event before running the hook.

I fiddled around with existing clocks and references.
I think we need a completely new one for the purpose.

I canā€™t reuse view.ref because server initiated events would mess up current button references when synchronizing the clocks.

I also canā€™t reuse underlying transport numbering because keep-alive messages could mess up the clocks.

So I believe, I would have to add the clock to the payload.