About liveview-patterns
A reference skill for building applications with Phoenix LiveView 1.0 and 1.1. It gives implementation patterns for asynchronous data loading, streams, forms, navigation, and component choices, with guidance on avoiding common lifecycle and memory pitfalls.
Key features
- 01Patterns for async assigns and data loading
- 02Stream-based handling of lists
- 03Lifecycle-aware PubSub subscriptions
- 04Form validation and error handling
- 05Navigation and component decision guides
Use cases
- 01Load data safely during LiveView mount
- 02Render large lists with streams
- 03Prevent duplicate PubSub subscriptions
- 04Choose navigation and component patterns
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ oliver-kriska/ claude-elixir-phoenix --skill liveview-patternsAfter installing, type/liveview-patternsin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/async-streams.mdchannels-presence.mdcomponents.mdforms-uploads.mdjs-interop.mdpubsub-navigation.mdDetails
From the project READMELiveView Patterns Reference
Ash projects: Use
ash-frameworkskill forAshPhoenix.Form. Lifecycle:AshPhoenix.Form.validate/3onphx-change,AshPhoenix.Form.submit/2on submit,to_form/1for HEEx. Do not useEcto.Changeset.cast/3.
Reference for building with Phoenix LiveView 1.0/1.1.
Iron Laws — Never Violate These
- NO UNCONDITIONAL DB QUERIES IN MOUNT — Mount runs TWICE. Default:
assign_async. SEO routes:connected?guard + cache-backed disconnected branch (crawlers read that HTML) - ALWAYS USE STREAMS FOR LISTS — Regular assigns = O(n) memory per user. Streams = O(1)
- CHECK connected?/1 BEFORE SUBSCRIPTIONS — Prevents double subscriptions
- EXTRACT VARIABLES BEFORE assign_async CLOSURE — Closures copy entire referenced variables
- LOAD PRIMARY DATA IN mount/3, PAGINATION IN handle_params/3 — handle_params runs on EVERY URL change
- NEVER PASS SOCKET TO BUSINESS LOGIC — Extract data before calling contexts
- CHECK CHANGESET ERRORS BEFORE UI DEBUGGING — Silent form save = check
{:error, changeset}first, not viewport/JS - HIDDEN INPUTS FOR ALL REQUIRED EMBEDDED FIELDS — Every required field in an embedded schema MUST have a
hidden_inputif not directly editable - NEVER USE
assign_newFOR LIFECYCLE VALUES —assign_newskips the function if key exists. Useassign/3for locale, current user, or any value refreshed every mount - MATCH
{:error, %Ecto.Changeset{}}EXPLICITLY — Bare{:error, _}merges changeset and non-changeset errors; the form silently never re-renders validation errors. Handle other errors separately
Memory Impact
| Pattern | 3K items | 10K users × 10K items |
|---|---|---|
| Regular assigns | ~5.1 MB | ~10+ GB |
| Streams | ~1.1 MB | Minimal (O(1)) |
Decision: Lists with >100 items → Use streams, not assigns
Quick Patterns
Async Assigns (CRITICAL)
def mount(%{"slug" => slug}, _session, socket) do
# Extract needed values BEFORE the closure
scope = socket.assigns.current_scope
{:ok,
socket
|> assign_async(:org, fn -> {:ok, %{org: fetch_org(scope, slug)}} end)}
end
Streams for Lists
def mount(_params, _session, socket) do
{:ok, stream(socket, :items, Items.list_items())}
end
# Insert/update/delete
stream_insert(socket, :items, item, at: 0)
stream_delete(socket, :items, item)
SEO Dead-Render (cache-backed disconnected branch)
For public/SEO-visible routes (marketing, articles, product listings) the disconnected render IS the HTML crawlers see. Fetch from a cache there, real data on connect:
def mount(_params, _session, socket) do
products =
if connected?(socket),
do: Catalog.list_products(),
else: Cache.get_products() || []
{:ok, assign(socket, products: products)}
end
Empty list → <noscript>-friendly skeleton. Cache → :persistent_term, ETS,
or Cachex. This satisfies Iron Law #1 AND keeps Googlebot/GPTBot happy.
PubSub with connected? check
def mount(_params, _session, socket) do
if connected?(socket), do: Chat.subscribe(room_id)
{:ok, socket}
end
Navigation Decision Tree
Same LiveView, different params? → patch / push_patch
Different LiveView, same live_session? → navigate / push_navigate
Different live_session or non-LiveView? → href / redirect
Component Decision Tree
Does component need BOTH internal state AND event handling?
│
├── YES → Does it encapsulate APPLICATION logic (not just DOM)?
│ ├── YES → Use LiveComponent ✅
│ └── NO → Refactor to function component with parent handling
│
└── NO → Use Function Component ✅
Official guidance: "Prefer function components over live components"
Common Anti-patterns
| Wrong | Right |
|---|---|
DB queries without assign_async | Use assign_async for mount queries (SEO routes: connected? + cached dead render) |
assign(socket, items: list) for lists | stream(socket, :items, list) |
PubSub subscribe without connected? | if connected?(socket), do: subscribe() |
| Passing socket to context functions | Extract socket.assigns first |
Business logic in handle_event | Delegate to context |
assign_new for locale/user in hooks | assign/3 (must run every mount) |
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/async-streams.md- assign_async, stream_async, streams${CLAUDE_SKILL_DIR}/references/forms-uploads.md- Forms, validation, file uploads${CLAUDE_SKILL_DIR}/references/components.md- Function components, LiveComponents${CLAUDE_SKILL_DIR}/references/pubsub-navigation.md- PubSub, navigation, JS commands${CLAUDE_SKILL_DIR}/references/js-interop.md- Third-party JS libraries, phx-update="ignore", hooks${CLAUDE_SKILL_DIR}/references/channels-presence.md- Phoenix Channels, Presence, token auth