LiveConnected
Defer a Phoenix LiveView's mount/3 and handle_params/3 until the socket
connects. For pages that don't need SSR/SEO, this skips the expensive
mount/handle_params work on the static (dead) render and shows a skeleton
instead, running the real work only once the socket connects.
A LiveView's mount and handle_params run twice on first load — once for the
static HTML that the browser is about to throw away, then again in the connected
LiveView process. For dashboards, authenticated app pages, and anything else
behind a login that search engines never see, the dead-render pass is wasted
work. LiveConnected skips it and paints a skeleton.
Installation
def deps do
[
{:live_connected, "~> 0.1.0"}
]
end
Usage
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
use LiveConnected
def mount(_params, _session, socket) do
{:ok, assign(socket, :stats, Analytics.expensive_dashboard())}
end
def render(assigns) do
~H"<Dashboard.stats stats={@stats} />"
end
# optional — omit for a generic shimmer skeleton
def loading(assigns), do: ~H"<Dashboard.skeleton />"
end
You write a completely normal LiveView: ordinary mount/3, handle_params/3,
and render/1. No renamed callbacks, no restructured bodies. Adding the behavior
is just use LiveConnected plus an optional loading/1.
Ordering: put use LiveConnectedafteruse MyAppWeb, :live_view
use MyAppWeb, :live_view
use LiveConnected # <- must come second
That ordering ensures the ~H sigil and LiveView's callback defaults are in
scope when LiveConnected compiles its wrappers around your callbacks. Putting
it first will not work.
How the deferral works
mount/3 runs twice on first load: once during the static HTTP render (the
"dead render", where connected?(socket) == false), then again in the spawned
LiveView process once the WebSocket connects. handle_params/3 runs in both
phases too.
LiveConnected wraps your own callbacks with @before_compile +
defoverridable + super and decides whether to call them per phase. On the
dead render the mount wrapper assigns live_connected?: false and returns
without calling your mount; the handle_params wrapper no-ops; and the
render wrapper calls loading/1 instead of your render/1. On connect it
assigns live_connected?: true and calls super, so everything runs normally.
All phase state keys off the single :live_connected? assign, read defensively
so a missing flag fails toward "run normally".
Including the CSS
The default skeleton and LiveConnected.Skeletons components are styled by a
dependency-free stylesheet. Import it from your app CSS:
/* assets/css/app.css */
@import "../../deps/live_connected/priv/static/live_connected.css";
Adjust the relative depth to match your asset pipeline. The shimmer is CSS-only
(no JS) and disables itself under prefers-reduced-motion. Tune the look with
custom properties (--lc-skeleton-base, --lc-skeleton-shine, etc.).
Reusable skeleton components
To avoid layout shift, match your eventual layout with the optional components:
def loading(assigns) do
~H"<LiveConnected.Skeletons.table rows={5} cols={4} />"
end
card/1, table/1, and list/1 are available. They are small and optional.
Opting out
When a page later needs SSR/SEO, make it behave like a normal LiveView:
use LiveConnected, enabled: false
It then runs everything in both phases — no deferral, no skeleton.
Caveats
Template-file LiveViews
If your LiveView renders from a colocated .heex template file instead of a
render/1, there is no render/1 for LiveConnected to wrap, so the skeleton
cannot take over the dead render. LiveConnected emits a compile-time warning in
this case. Add a render/1 (even a one-liner that calls your template) to get
the skeleton.
Status codes and redirects move to the connected phase
Deferring handle_params means "resource missing → 404" or an ownership
redirect now happens on connect, not on the dead render. The static response
becomes a 200 with a skeleton, and the user sees a brief flash of skeleton
before any bounce. For the SEO-irrelevant pages this library targets, that is
acceptable — but be aware of it.
When you need a correct dead-render status, use the cheap-guard pattern: keep
a trivial existence/authz check that runs in both phases, and defer only the
expensive load. Branch on @live_connected? inside your own callback:
def handle_params(%{"id" => id}, _uri, socket) do
cond do
not socket.assigns.live_connected? ->
# cheap guard runs on the dead render -> correct 404
if Catalog.exists?(id),
do: {:noreply, socket},
else: {:noreply, push_navigate(socket, to: ~p"/not-found")}
true ->
{:noreply, assign(socket, :product, Catalog.full_product!(id))}
end
end
This still relies on use LiveConnected calling handle_params on the dead
render with live_connected?: false assigned, so your not @live_connected?
branch runs the cheap check while the expensive branch is skipped until connect.
The tradeoff: you write one explicit branch, but you keep a correct status code
without giving up deferral of the heavy work.
Live patches need no special handling
After the first connect the LiveView is always connected, so deferred
handle_params runs normally on every push_patch / <.link patch>. There is
nothing to configure.
How this relates to assign_async
assign_async/start_async solve non-blocking loads within the connected
view — they let the connected render paint immediately and stream data in as it
arrives. LiveConnected is about skipping the dead-render work entirely. They
are complementary and combine well: use LiveConnected to skip the static pass,
then assign_async inside your connected mount to keep the connected render
snappy.
Non-goals
This is intentionally a thin, ~one-file wrapper over the connected?/1 pattern.
Its value is ergonomics, the skeleton, and the opt-out — not cleverness. If you
need SSR/SEO, you want a normal LiveView (or enabled: false); this library is
deliberately not that.
License
MIT — see LICENSE.