Skip to main content
ClaudeWave
Skill440 repo starsupdated today

liveview-patterns

Liveview-Patterns provides an Elixir Phoenix LiveView 1.0/1.1 reference guide covering critical patterns for handling real-time interactions. Use this skill when building forms with async data loading via assign_async, implementing PubSub subscriptions with connected? guards, managing list rendering with streams for memory efficiency, handling phx-change events, building form components and modals, or implementing live navigation with live_patch. Reference the iron laws and memory tables to avoid common pitfalls like database queries in mount or inefficient list assignments.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/oliver-kriska/claude-elixir-phoenix /tmp/liveview-patterns && cp -r /tmp/liveview-patterns/plugins/elixir-phoenix/skills/liveview-patterns ~/.claude/skills/liveview-patterns
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# LiveView Patterns Reference

> **Ash projects**: Use `ash-framework` skill for `AshPhoenix.Form`. Lifecycle: `AshPhoenix.Form.validate/3` on `phx-change`, `AshPhoenix.Form.submit/2` on submit, `to_form/1` for HEEx. Do not use `Ecto.Changeset.cast/3`.

Reference for building with Phoenix LiveView 1.0/1.1.

## Iron Laws — Never Violate These

1. **NO UNCONDITIONAL DB QUERIES IN MOUNT** — Mount runs TWICE. Default: `assign_async`. SEO routes: `connected?` guard + cache-backed disconnected branch (crawlers read that HTML)
2. **ALWAYS USE STREAMS FOR LISTS** — Regular assigns = O(n) memory per user. Streams = O(1)
3. **CHECK connected?/1 BEFORE SUBSCRIPTIONS** — Prevents double subscriptions
4. **EXTRACT VARIABLES BEFORE assign_async CLOSURE** — Closures copy entire referenced variables
5. **LOAD PRIMARY DATA IN mount/3, PAGINATION IN handle_params/3** — handle_params runs on EVERY URL change
6. **NEVER PASS SOCKET TO BUSINESS LOGIC** — Extract data before calling contexts
7. **CHECK CHANGESET ERRORS BEFORE UI DEBUGGING** — Silent form save = check `{:error, changeset}` first, not viewport/JS
8. **HIDDEN INPUTS FOR ALL REQUIRED EMBEDDED FIELDS** — Every required field in an embedded schema MUST have a `hidden_input` if not directly editable
9. **NEVER USE `assign_new` FOR LIFECYCLE VALUES** — `assign_new` skips the function if key exists. Use `assign/3` for locale, current user, or any value refreshed every mount

## 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)

```elixir
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

```elixir
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:

```elixir
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

```elixir
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 all queries |
| `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