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.