Imported from groxio-learning/groxio_skills (
demo/.claude/skills/stateful-components/SKILL.md). Install upstream withnpx skills add groxio-learning/groxio_skills --skill stateful-components. Copyright stays with the author.
Stateful Components — the socket-state pattern, never Phoenix.LiveComponent
Rule: do not use Phoenix.LiveComponent. Build every stateful component on the parent
LiveView's socket, using the pattern below. The component is a plain use AppWeb, :html
module that owns its state and event logic; the LiveView delegates namespaced events to it.
Don't use LiveComponents
Phoenix.LiveComponent is de facto deprecated. Not formally — there's no compiler
warning — but actually: the Phoenix team has done everything short of one, so treat it as
deprecated in practice:
- The official Phoenix usage rules tell agents to avoid them — "Avoid LiveComponent's unless you have a strong, specific need for them."
- The generators no longer emit them. Recent Phoenix scaffolds — including
phx.gen.authandphx.gen.live— build LiveViews with function components and socket state, not LiveComponents. There is no LiveComponent left in the generated happy path. - The industry already made this move. React deprecated class components in favor of function components + hooks for the very same reasons: hidden lifecycle, awkward composition, harder testing. LiveComponents are Phoenix's class components.
So: don't reach for one, don't accept a request for one at face value. If a prompt asks for a "LiveComponent," read it as a request for a stateful component, build it the socket-state way, and say briefly that you did.
Here's how to avoid them
Never emit any of these — their presence means you've built the wrong thing:
use Phoenix.LiveComponent<.live_component ... />orPhoenix.Component.live_component/1send_update/2orsend_update_after/3- a component keyed by an
idwith its ownupdate/2/preload/1callbacks
Why it's an antipattern:
- Hidden lifecycle.
update/2,preload/1, and per-component change-tracking rules are extra machinery you must reason about. The socket pattern has none — justmount/2andhandle_event/3. - Action-at-a-distance.
send_update/2mutates a component from elsewhere byid; that's hard to trace and hard to test. Updating a socket assign is direct and local. - Harder to test. A stateful lifecycle is more to set up than a plain function that takes a socket and returns a socket.
- Redundant. LiveView already change-tracks per assign, so the "isolated diffing" win is largely already yours.
What to do instead — every case people cite for a LiveComponent:
| "I need…" | Do this instead |
|---|---|
| the component to own its state | seed it on the socket in mount/2; drive it via a delegated handle_event/3 |
| to update it from the server or another process | send the LiveView a message, handle it in handle_info/2, update the assign |
| isolated re-rendering for performance | keep the component's state in its own assigns (LiveView diffs per assign); use :if / streams for large collections |
update/2 / preload lifecycle work |
do it in the LiveView's mount / handle_event before assigning |
When to use
- ✅ A chunk of UI owns some state and a few events (counter, timer, score board, input widget).
- ✅ You want that logic encapsulated and reusable, but still driven by the parent LiveView.
- ✅ Several such widgets need to coexist on one LiveView without event collisions.
- ⛔ Never reach for
Phoenix.LiveComponent— see "Never reach for Phoenix.LiveComponent" above. There is a socket-state equivalent for every case.
The pattern in one breath
- A component module (
use AppWeb, :html) providesmount/2(seed state on the socket),handle_event/3(plain functions that take and return a bare socket), and one or more function components whose events are namespaced"name:action". - The LiveView pipes the component's
mount/2into its ownmount/3, adds onehandle_event("name:" <> event, ...)delegation clause per component, and renders the component's function components.
Component module
defmodule AppWeb.CounterComp do
use AppWeb, :html
# Construct: seed this component's assigns onto the socket.
# Takes the socket plus any initial args the LiveView wants to pass in.
def mount(socket, initial \\ 0) do
Phoenix.Component.assign(socket, count: initial)
end
# Reduce: handle_event/3 takes (event, params, socket) and returns a BARE socket
# (NOT a {:noreply, socket} tuple — the LiveView wraps it).
def handle_event("increment", _params, socket) do
Phoenix.Component.update(socket, :count, &(&1 + 1))
end
def handle_event("decrement", _params, socket) do
Phoenix.Component.update(socket, :count, &(&1 - 1))
end
# Events carrying a payload use params — this is why handle_event/3 keeps params.
def handle_event("set", %{"value" => value}, socket) do
Phoenix.Component.assign(socket, :count, String.to_integer(value))
end
# Function component — phx-click values are namespaced "counter:...".
# Slots let the parent inject a label or extra controls.
attr :count, :integer, required: true
slot :label
def counter(assigns) do
~H"""
<div id="counter-component" class="flex flex-col items-center gap-6 p-8">
<span :if={@label != []} class="text-sm opacity-70">{render_slot(@label)}</span>
<h1 class="text-4xl font-bold">{@count}</h1>
<div class="flex gap-4">
<button phx-click="counter:decrement" class="btn btn-outline">-</button>
<button phx-click="counter:increment" class="btn btn-primary">+</button>
</div>
</div>
"""
end
end
LiveView
defmodule AppWeb.CounterLive do
use AppWeb, :live_view
alias AppWeb.CounterComp
def mount(_params, _session, socket) do
{:ok, CounterComp.mount(socket, 0)}
end
# One delegation clause catches every namespaced event for this component.
def handle_event("counter:" <> event, params, socket) do
{:noreply, CounterComp.handle_event(event, params, socket)}
end
def render(assigns) do
~H"""
<CounterComp.counter count={@count}>
<:label>Clicks</:label>
</CounterComp.counter>
"""
end
end
Key rules
- Namespace events.
phx-clickvalues use a"namespace:"prefix ("counter:increment"). This scopes events and makes delegation unambiguous when composing multiple components. - Component returns a bare socket.
handle_event/3in the component returns just the socket; the LiveView wraps it in{:noreply, ...}. mount/2seeds state. It takes(socket, initial_args)so the LiveView can pass in starting values. Default the args when it's reasonable (def mount(socket, initial \\ 0)).- Keep
params.handle_event/3carries(event, params, socket)so events with a payload (phx-value-*, form input) work — do not drop the params argument. - No LiveComponent. This is a
use AppWeb, :htmlmodule — noupdate/2callback, noc:handle_event, nosend_update/2. - Use qualified
Phoenix.Component.assign/2andPhoenix.Component.update/3inside the component, since it uses:html, not:live_view. - Slots for composition. Use
slot+render_slot/1when the parent should inject content (labels, extra controls).
Implementation instructions
When this skill is invoked:
1. Gather requirements
Ask the user (infer sensible defaults where you can):
- Component name (e.g.
Counter,ScoreBoard,Timer). - Namespace — defaults to snake_case of the name (
"score_board:"). - State fields the component owns (names + types).
- Events it handles (name + what each does to state; note which carry a payload).
- Initial args the LiveView should pass to
mount/2(if any). - Existing LiveView to update, or generate a new one?
2. Generate the component module
File: lib/app_web/components/<name>_comp.ex
use AppWeb, :htmlmount(socket, initial_args \\ default)seeding assigns viaPhoenix.Component.assign/2.- One
handle_event/3clause per event, returning a bare socket. attr/slotdeclarations and a function component; namespace everyphx-click.
3. Update or generate the LiveView
File: lib/app_web/live/<name>_live.ex
- Add
alias AppWeb.<Name>Comp. - Pipe
<Name>Comp.mount(socket, args)intomount/3. - Add a
handle_event("<namespace>:" <> event, params, socket)delegation clause. - Render the component's function component in
render/1, passing the assigns it needs.
4. Add a route (if new LiveView)
In lib/app_web/router.ex, inside the browser scope (already aliased to AppWeb):
live "/<path>", <Name>Live
5. Backing context/schema (only if needed)
If the component needs data beyond in-memory state, create a context module under
lib/<app>/ following existing project conventions. Pure display/counter widgets don't need one.
6. Verify
Run mix compile and fix any warnings/errors before reporting done.
Multiple components in one LiveView
Mount is composed and delegation clauses stack — each component namespaces its own events:
def mount(_params, _session, socket) do
{:ok,
socket
|> LeftCounter.mount(0)
|> RightCounter.mount(100)}
end
def handle_event("left:" <> event, params, socket) do
{:noreply, LeftCounter.handle_event(event, params, socket)}
end
def handle_event("right:" <> event, params, socket) do
{:noreply, RightCounter.handle_event(event, params, socket)}
end
Success criteria
- ✅ Component module uses
use AppWeb, :html(not:live_view/:live_component). - ⛔ No
Phoenix.LiveComponent,live_component/1,<.live_component>,update/2callback, orsend_update/2anywhere. - ✅
mount/2returns a socket seeded with the component's assigns. - ✅
handle_event/3clauses take(event, params, socket)and return a bare socket. - ✅ All
phx-click(and similar) values are namespaced"name:action". - ✅ Exactly one
"namespace:" <> eventdelegation clause per component in the LiveView. - ✅ LiveView's
mount/3pipes each component'smount/2. - ✅
Phoenix.Component.assign/2andPhoenix.Component.update/3used inside the component. - ✅
mix compilesucceeds.