JS Commands persisting across socket reconnections when using LiveView Streams

I’m working on implementing an inline-edit type functionality on top of LiveView streams, but I’m having trouble with socket reconnects losing the state from JS commands (i.e. add_class or remove_class). The same behavior doesn’t happen when I use normal assigns (instead of a stream). I’m curious if anybody knows whether this is intentional or a bug?

For more context, I’m trying to display a list of widgets, where each widget has an edit icon next to it. I’m achieving this by rendering a form (with a unique id) with a “hidden” class, along with the read-only widget view. When user clicks the edit icon, I use JS.add_class("hidden", to: "read-only-#{id}") to hide the read-only content, and JS.remove_class("hidden", to: "edit-form-#{id}") to then show the form content.

This all works really well, but if I do a deployment or if the users socket reconnects, then the client side state seems to get reset and it goes back to the read-only state. Since I anticipate a number of users being on mobile where they might lose connection fairly frequently, this can be a pretty bad UX if they’re mid-edit.

If I switch to iterating the list of widgets from an assigns (rather than a stream), then the state is persisted across socket reconnections, but then I’m worried about memory usage here.

I’m aware that I can do things such as putting the edit state in the URL (for example /widgets?edit=123) and then having the edit button be a <.link patch={~p"/widgets?edit=#{id}"}> but I don’t like that there is slight latency when clicking edit as this requires server interaction.

Does anybody have recommendations on how to approach or fix this? I’d really like to be able to use both streams and JS commands for managing this state, and not relying on the URL.

Hmm, the open/close widget editing state has to live somewhere – and potentially other work in progress editing state like widget name/description.

If you want to keep that state on the client, you could write a client side phx-hook to “store” that state on disconnected and “restore” on mounted and reconnected. Here’s an article from Fly on Saving and Restoring LiveView State · The Phoenix Files that should be helpful.

If you want to keep that state on the server, you could chain together JS.push with JS.add/remove_class to track those changes to widget editing state in the socket assigns. See the hide_modal example in the JS Commands guide.

Thanks for the reply! I was hoping I could keep “the DOM” as the state, which seems to work well when I’m rendering the widgets outside of streams. The docs also state that the JS commands are “DOM-patch aware, so operations applied by the JS APIs will stick to elements across patches from the server”. However, it seems that doesn’t apply to streams, unfortunately. I use that technique for toggling an “Add item” form which works really well.

I’ll look into a hook to store the state, though I’ll admit I was hoping for a more simple solution. I’ve tried using JS.push to have something like a “currently_editing: 123”, but when that would change, the previously editing widgets wouldn’t close (I guess the stream item doesn’t get updated?), however I can try also doing the add/remove class stuff manually. I don’t think this would survive a reconnect on its own though, as that state would get lost on server restart or client reconnect.

It seems that the best approach is some kind of optimistic UI here, where I’m chaining JS.push to emit an event that updates the URL with the currently editing item, along with add/remove_class commands so the user doesn’t have to wait for that roundtrip.

I feel like this is a pretty common pattern, and I’m really surprised that there doesn’t seem to be a straight forward way to do this that would survive server restarts or socket reconnects.