So my experience with LiveView has been good so far but I always come back to this single issue I cannot figure out a good solution for: persistent client-side components and navigation.
I frequently need to introduce Svelte or React components to my apps because otherwise I’d need to spend irrational amounts of time to build something like a map component or a complex form with animations. One such component is a dashboard sidebar that handles user info, navigation, switching between accounts / workspaces, etc. with lots of nested menus, dropdowns, mobile support, etc.
Let’s say I have a SaaS product.
- on
/home, /about
we render some basic landing HTML without liveview - on
/dash, /dash/X, /dash/Y /dash/Y/:param, /dash/Z
we render complex LiveViews with many subcomponents and view-specific events.
For all “dash” routes except /dash/Z
, I need a complex sidebar. I pull a 3rd party react component that handles all the logic and keeps local state in React Context or useState or a combination of these. There is a client-side state about things like opened dropdowns inside the sidebar, text fields user is editing, emoji icon selector, etc. Sidebar obviously has links to other pages in the dashboard (e.g. from /dash/X
to /dash/Y
). I wrap it into my own React component to accept props and push events within a LiveView (e.g. serialized current_user
, pushEvent("save_username")
).
Now we need to figure out where to mount this component so it doesn’t loose client-side state on navigation between LiveViews. Here are the options I found so far:
-
The trivial approach would be mounting wrapper React component inside each LiveView. This has some downsides though. If user navigates from
/dash/X
to/dash/Y
, Phoenix changes LiveView, which leads to loosing client side state stored in React Context. To solve this, we need to pluck state management out of the React Context into something global. Sure thing it’s possible, but it would require reworking 3rd party components, which kinda defeats the objective and will lead to complex logic if component is used multiple times on the same page. Even if we rework the components to use global state, we still need to deal with potential flickering because components are re-mounted on live navigation. Note that LiveBeats approach withLiveBeatsWeb.Nav
is not related to this issue, but it works almost as described here afaiu. If you go https://livebeats.fly.dev/, open developer tools, disable cache, and navigate between “settings” and “my songs”, you’ll see that profile image “flickers” / gets reloaded after each navigation. -
Use single LiveView across dashboard for all routes and implement navigation within single LiveView using sub components. This was discussed here Structuring a live view project with proper navigation - #2 by chautelly, but I didn’t explore this route because I believe it will become insanely hard to maintain after more routes are added (users, orders, posts, items, etc.).
-
Render Sidebar as separate LiveView in
root.html.heex
with something like
<%= if assigns[:has_navigation] do %>
<%= live_render(@conn, App.SidebarLive, session: %{"user_id" => assigns[:current_user].id, "current_page_id" => assigns[:current_page_id]}) %>
<% end %>
with :current_page_id
assigned by the “content” liveviews we get from router. It works on first render, but we need to update it… As we know, LiveView doesn’t allow to update root layouts from LiveViews by design (Live layouts — Phoenix LiveView v0.20.17), so this won’t work.
- Render separate LiveView in root layout and use PubSub to share specific assigns between 2 LiveViews (
SidebarLive
and the one that is rendered fromrouter
)
<%= live_render(@conn, App.SidebarLive, session: %{"initial_user_id" => assigns[:current_user].id, "initial_current_page_id" => assigns[:current_page_id]}) %>
This would work. It seems similar approach was discussed here What will happen to my LiveView component if I go to the next page? - #24 by chrismccord and here LiveView with complex layouts - #29 by reddy. But involving PubSub for UI changes worries me a lot for some reason. This just doesn’t feel right for a sidebar component. Maybe I’m wrong and it’s OK to utilize PubSub for this?
- Embrace client-side state instead of PubSub - render separate LiveView like in option 4. but instead of using PubSub, attach to dom updates in JS in regular LiveViews and update Sidebar component using native DOM events / shared document-level objects. Example with events:
# app.html.heex
# note: we could use window event listener in JS instead of phx-mounted
<div class="hidden" phx-mounted={JS.dispatch("app:sidebar:update_page_id", detail: %{current_page_id: @current_page_id, has_navigation: @has_navigation})}>
# in react component or in plain javascript
useEffect(() => {
const updatePageId = (info: any) => {
// client-side change if sidebar server state doesn't care about this
setCurrentPage(info.detail.current_page_id);
// or server-side driven UI change
// we `pushEvent` to the server, which will update sidebar server
// state and will trigger an update in react component **preserving**
// client-side component state
live.pushEvent("update_current_page_id", {
page_id: info.detail.current_page_id,
has_navigation: info.detail.has_navigation
})
}
window.addEventListener("app:sidebar:update_page_id", updatePageId)
return () => {
window.removeEventListener("app:sidebar:update_page_id", updatePageId);
}
}, [setCurrentPage]);
This approach moves assigns
sharing logic between LiveViews from PubSub to the browser. This works and doesn’t require PubSub communication between LiveViews for UI. So instead of action->PubSub->effect
, we have action->browser->effect
.
Last 2 also have a couple of flaws:
- Each LiveView has it’s own process and the “Sidebar” LV is not mounted from router. So in order to request navigation to different state or even “put_flash”, we need to send messages over PubSub to our “regular” LiveViews. We can replace the PubSub for navigation and flash the same way with browser events, but navigation and put_flash logic belongs server-side so I’d rather keep it on server, in secure & reliable PubSub and not rely on client browser.
- We have to always keep that LiveView alive for each active session, even if sidebar is not needed on some pages.
Curious to hear how others approach this problem.
Maybe there is a better and simpler solution to this? Maybe it’s possible to do without using a separate LiveView?