smithery/code0100fun

liveview-guidelines

Phoenix LiveView patterns covering streams, JavaScript interop (hooks, colocated scripts, push events), testing, and form handling. Use when building or reviewing LiveView features.

Installation

$ npx skills add smithery/code0100fun --skill liveview-guidelines

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/code0100fun.

npx skills add smithery/code0100fun

Browse all from smithery/code0100fun

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 7,301 B
  • docs SUMMARY.md 208 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

Phoenix LiveView Guidelines

General

  • Never use the deprecated liveredirect and livepatch functions, instead always use <.link navigate={href}> and <.link patch={href}> in templates, and pushnavigate and pushpatch in LiveViews
  • Avoid LiveComponents unless you have a strong, specific need for them
  • LiveViews should be named like AppWeb.WeatherLive, with a Live suffix. When adding routes, the default :browser scope is already aliased with the AppWeb module, so you can just do live "/weather", WeatherLive

LiveView Streams

  • Always use LiveView streams for collections instead of assigning regular lists to avoid memory ballooning and runtime termination:

- Basic append: stream(socket, :messages, [newmsg]) - Reset stream: stream(socket, :messages, [newmsg], reset: true) (e.g. for filtering) - Prepend: stream(socket, :messages, [newmsg], at: -1) - Delete: streamdelete(socket, :messages, msg)

  • When using streams, the template must:

1. Set phx-update="stream" on the parent element with a DOM id 2. Consume @streams.stream_name and use the id as the DOM id for each child:

<div id="messages" phx-update="stream">
  <div :for={{id, msg} <- @streams.messages} id={id}>
    {msg.text}
  </div>
</div>
  • LiveView streams are not enumerable. You cannot use Enum.filter/2 or Enum.reject/2 on them. To filter or refresh, refetch the data and re-stream with reset: true:
def handle_event("filter", %{"filter" => filter}, socket) do
  messages = list_messages(filter)

  {:noreply,
   socket
   |> assign(:messages_empty?, messages == [])
   |> stream(:messages, messages, reset: true)}
end
  • Streams do not support counting or empty states. Track counts with a separate assign. For empty states, use Tailwind:
<div id="tasks" phx-update="stream">
  <div class="hidden only:block">No tasks yet</div>
  <div :for={{id, task} <- @streams.tasks} id={id}>
    {task.name}
  </div>
</div>

The empty state only works if it's the only HTML block alongside the stream for-comprehension.

  • When updating an assign that should change content inside streamed items, you MUST re-stream the items along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
  message = Chat.get_message!(message_id)
  edit_form = to_form(Chat.change_message(message, %{content: message.content}))

  {:noreply,
   socket
   |> stream_insert(:messages, message)
   |> assign(:editing_message_id, String.to_integer(message_id))
   |> assign(:edit_form, edit_form)}
end
  • Never use the deprecated phx-update="append" or phx-update="prepend" for collections

JavaScript Interop

  • When using phx-hook="MyHook" and that JS hook manages its own DOM, you must also set phx-update="ignore"
  • Always provide a unique DOM id alongside phx-hook

Inline Colocated JS Hooks

Never write raw embedded <script> tags in HEEx. Instead, always use colocated js hook script tags:

<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
  export default {
    mounted() {
      this.el.addEventListener("input", e => {
        let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
        if(match) {
          this.el.value = `${match[1]}-${match[2]}-${match[3]}`
        }
      })
    }
  }
</script>
  • Colocated hooks are automatically integrated into the app.js bundle
  • Colocated hook names MUST ALWAYS start with a . prefix (e.g., .PhoneNumber)

External phx-hook

External JS hooks must be placed in assets/js/ and passed to the LiveSocket constructor:

const MyHook = {
  mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { MyHook }
});

Pushing Events Between Client and Server

Use push_event/3 to push events/data to the client for a phx-hook to handle. Always return or rebind the socket:

socket = push_event(socket, "my_event", %{...})

# or return directly:
def handle_event("some_event", _, socket) do
  {:noreply, push_event(socket, "my_event", %{...})}
end

Receive in JS hook with this.handleEvent:

mounted() {
  this.handleEvent("my_event", data => console.log("from server:", data));
}

Push from client to server with reply:

mounted() {
  this.el.addEventListener("click", e => {
    this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply:", reply));
  })
}

Server handles with reply:

def handle_event("my_event", %{"one" => 1}, socket) do
  {:reply, %{two: 2}, socket}
end

LiveView Tests

  • Use Phoenix.LiveViewTest module and LazyHTML (included) for assertions
  • Form tests are driven by rendersubmit/2 and renderchange/2
  • Split test cases into small, isolated files. Start with simpler content-existence tests, then add interaction tests
  • Always reference key element IDs from your templates in tests for element/2, has_element/2, etc.
  • Never test against raw HTML; always use element/2, has_element/2, and similar
  • Focus on testing outcomes rather than implementation details
  • Be aware that Phoenix.Component functions may produce different HTML than expected. Test against output structure
  • When facing test failures with selectors, debug with LazyHTML:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")

Form Handling

Creating a Form from Params

def handle_event("submitted", params, socket) do
  {:noreply, assign(socket, form: to_form(params))}
end

When passing a map to to_form/1, it assumes string keys. You can specify a name:

def handle_event("submitted", %{"user" => user_params}, socket) do
  {:noreply, assign(socket, form: to_form(user_params, as: :user))}
end

Creating a Form from Changesets

%YourApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()

In the template:

<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
  <.input field={@form[:field]} type="text" />
</.form>

Always give the form an explicit, unique DOM ID.

Avoiding Form Errors

Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template:

<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
  <.input field={@form[:field]} type="text" />
</.form>

Never do this:

<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
  <.input field={@changeset[:field]} type="text" />
</.form>
  • You are FORBIDDEN from accessing the changeset in the template
  • Never use <.form let={f} ...>, always use <.form for={@form} ...> and drive all form references from the form assign