Filament
Copy MarkdownFilament is a component and state-management layer for Phoenix LiveView. It
brings a JSX-like component model, React-style hooks, and observable GenServers
to Elixir — so you can build rich real-time UIs without spreading state across
socket assigns, handle_event callbacks, and manual PubSub wiring.
defmodule CartWeb.Components.CartView do
use Filament.Component
defcomponent do
prop(:cart_id, :string, required: true)
def render(%{cart_id: cart_id}) do
cart = use_observable({:via, Registry, {Cart.Registry, cart_id}}, fn
:disconnected -> nil
state -> state
end)
~F"""
<div class="cart">
<header>
<CartBadge cart_id={cart_id} />
</header>
{if cart do}
{for item <- cart.items do}
<div class="item">
<span>{item.name}</span>
<button on_click={fn -> Cart.Server.remove(cart_id, item.id) end}>
Remove
</button>
</div>
{end}
{end}
</div>
"""
end
end
end
defmodule CartWeb.Components.CartBadge do
use Filament.Component
defcomponent do
prop(:cart_id, :string, required: true)
def render(%{cart_id: cart_id}) do
count = use_observable({:via, Registry, {Cart.Registry, cart_id}}, fn
:disconnected -> 0
state -> Cart.State.item_count(state)
end)
~F"""
<span class="badge">{count} items</span>
"""
end
end
endWhat it does
JSX-like templates. The ~F sigil compiles HTML templates with
{expression} interpolation, {for item <- list do}…{end} loops, and
<MyComponent prop={value} /> child component tags — the same mental model as
JSX, in Elixir.
Components with typed props. defcomponent declares a component with
prop/3 — typed, validated, with required or default values. Each component
instance gets an isolated fiber with its own hook state and event handlers; no
more shared assign namespaces.
Hooks for local state. use_state/1 gives a component a piece of mutable
local state that persists across re-renders without touching the LiveView
socket. Calling the setter re-renders only the affected fiber.
{filter, set_filter} = use_state(:all)Observable GenServers. Wrap any GenServer with
use Filament.Observable.GenServer and components can subscribe to it with
use_observable/2. Call notify_observers(new_state) after a mutation and
every subscribed component re-renders automatically — no PubSub, no
handle_info wiring in the LiveView.
Because subscriptions run during the initial HTTP render, the page arrives with
real server data already in the HTML — no loading spinners, no client-side fetch
on first paint. When the WebSocket connects, Filament hands off the existing
subscription so the component picks up live updates seamlessly, without
re-fetching or re-running handle_subscribe.
Note: One place where this behavior might not be desirable (and you can easily turn it off) are observables that represent who is connected rather than what the data is — presence counts, online indicators, live cursors. The static render is not a real user session and should not count as one. Set
static_subscribe: falseon those LiveViews and the page will skip subscribing during the HTTP phase, showing the:disconnectedfallback briefly until the WebSocket is established.
Projections and change-or-bust. Pass a projection function as the second
argument to use_observable/2 to extract only the slice of state the component
cares about. The function receives :disconnected or the raw server state and
runs on the client at render time, so it can safely close over local component
state such as filters or selections. If the projected value is unchanged after a
mutation, the update is suppressed and the component does not re-render. This
keeps large UIs fast without manual shouldComponentUpdate logic.
# CartBadge only re-renders when the item count changes,
# not on every cart mutation.
count = use_observable({:via, Registry, {Cart.Registry, cart_id}}, fn
:disconnected -> 0
state -> Cart.State.item_count(state)
end)Automatic memoization. The ~F compiler automatically wraps closure
expressions and child component renders in memo_at calls. Stable subtrees
skip re-evaluation without any annotation from the component author.
Composable custom hooks. Any function that calls use_state,
use_observable, or use_effect is a custom hook. Domain behaviour — holds,
presence, pagination, debounce — lives in a plain module function rather than
scattered across mount/event/info callbacks.
# examples/inventory — use_hold composes use_observable + use_state
{held_qty, item, hold, release} = use_hold(server, item_id)Effects with cleanup. use_effect/2 runs a side effect after render, with
optional cleanup on re-run or unmount and dependency-based re-execution.
Incremental adoption. Start with Filament.LiveComponent to drop a
Filament component tree into any existing LiveView. Promote to
Filament.LiveView when you are ready — no big-bang rewrite required.
Fast, isolated tests. Filament's test API mounts a component tree
in-process with no browser or WebSocket needed. Tests run with async: true
and finish in milliseconds.
{:ok, view} = mount(TodoWeb.Components.TodoList, %{})
{:ok, view} = submit(view, "form", %{"text" => "Buy milk"})
assert render_text(view) =~ "Buy milk"Installation
# mix.exs
{:filament, "~> 0.2"}Examples
| Example | What it demonstrates |
|---|---|
examples/todo | defcomponent, use_state, use_observable with factory fn, rung-2 tests |
examples/cart | Observable.GenServer, projections, change-or-bust, rung-3 integration tests |
examples/inventory | Custom use_hold hook, handle_unsubscribe auto-release, per-item projections |
examples/collaboration | Multiple concurrent subscribers, real-time presence UI |
Guides
- Getting Started —
defcomponent, props,use_state, events, testing - Observables —
Observable.GenServer,use_observable, projections - Hooks — built-in hooks,
use_effect, composing custom hooks - Migration Guide — incrementally adopting Filament in an existing LiveView app