Getting Started
Copy MarkdownFilament is a process-aware UI framework for Phoenix LiveView that brings
React-style component composition and hooks to your Elixir applications. This
guide walks you through building a working TodoList component — the same one
in the examples/todo directory — so you understand defcomponent, props,
use_state, event closures, child components, and how to test everything with
Filament's rung-2 isolation API. By the end you will have a passing test suite
and a clear mental model of the Filament component lifecycle.
What you need
- Elixir 1.17+ and Phoenix LiveView 1.0+
- Add Filament to your project:
# mix.exs
{:filament, "~> 0.1"}- Run
mix deps.getand thenuse Filament.Componentin any module where you want to define components.
Defining a component
Every Filament component lives inside a defcomponent block. Here is the real
TodoItem component from examples/todo:
defmodule TodoWeb.Components.TodoItem do
use Filament.Component
defcomponent do
prop(:todo, :map, required: true)
prop(:on_toggle, :function, default: nil)
prop(:on_remove, :function, default: nil)
defp item_class(%{completed: true}), do: "completed"
defp item_class(_), do: ""
def render(%{todo: todo, on_toggle: on_toggle, on_remove: on_remove}) do
~F"""
<li class={item_class(todo)}>
<div class="view">
<input
class="toggle"
type="checkbox"
checked={todo.completed}
on_click={on_toggle}
/>
<label>{todo.text}</label>
<button class="destroy" on_click={on_remove}>×</button>
</div>
</li>
"""
end
end
endKey points:
use Filament.Componentimportsdefcomponent,prop, the~Fsigil, and all hooks into your module.prop(:name, :type, opts)declares a typed attribute. Passrequired: trueto make the prop mandatory ordefault: valueto make it optional.render/1receives a plain map of props — pattern-match directly.- Inside
~F""" ... """, use{expression}for interpolation and@prop_nameas a shorthand for the same — both resolve to local variables, not an assigns map. - Event attributes like
on_click={handler}accept zero-arity or one-arity functions; Filament wires them tophx-clickautomatically.
Local state with use_state
use_state/1 gives a component a piece of mutable local state. The
TodoList component uses it to track the active filter:
def render(%{title: title}) do
store = use_observable(fn -> Todo.Store.start_link([]) end)
todos = use_observable(store, fn :disconnected -> []; s -> s end)
{filter, set_filter} = use_state(:all)
filtered = apply_filter(todos, filter)
# ...
enduse_state(initial)returns{current_value, setter}.- On the first render,
current_valueisinitial. On subsequent renders it is whatever was last passed tosetter.(new_value). - Calling
setter.(new_value)sends a message to the owning LiveView, which triggers a re-render of only the affected fiber. - Rules of hooks: call
use_state(and all hooks) at the top level ofrender/1, never insideif,case, or comprehensions. Hook identity depends on call order.
Event closures
All Filament event handlers are plain Elixir closures attached to on_*
template attributes. The on_* name maps directly to the corresponding
Phoenix event: on_click → phx-click, on_submit → phx-submit,
on_change → phx-change, and so on. Filament wires the closure to a
filament:fiber_id:index ref automatically — you never write wire format by
hand.
The TodoList component handles form submission and item interactions
entirely through closures:
{text, set_text} = use_state("")
~F"""
<form on_submit={fn %{"text" => val} ->
if String.trim(val) != "", do: Todo.Store.add(store, val)
set_text.("")
end}>
<input name="text" class="new-todo" value={text} placeholder="What needs to be done?" />
</form>
<ul class="todo-list">
{for todo <- filtered do}
<TodoItem
todo={todo}
on_toggle={fn -> Todo.Store.toggle(store, todo.id) end}
on_remove={fn -> Todo.Store.remove(store, todo.id) end}
/>
{end}
</ul>
"""- Zero-arity closures receive no arguments; one-arity closures receive the
event params map (for
on_submit, the full form data; foron_change, the changed field map including_target). - Closures capture the render-time environment.
todo.idin the loop above is correctly bound per iteration, andset_textfromuse_stateis in scope insideon_submit. - Because closures own both the side effect and any state updates, there is no
handle_eventcallback to write — the closure is the handler.
Composing components and keyed lists
Child components are rendered with their module name as a tag in ~F templates:
~F"""
<section class="main">
<ul class="todo-list">
{for todo <- filtered do}
<TodoItem
todo={todo}
on_toggle={fn -> Todo.Store.toggle(store, todo.id) end}
on_remove={fn -> Todo.Store.remove(store, todo.id) end}
/>
{end}
</ul>
</section>
"""<TodoItem prop={value} />passes props to the child component exactly like HTML attributes.Each iteration of the
forloop creates a separate fiber tracked by position (index). For lists that can be reordered or have items deleted, add a:keyattribute:<TodoItem :key={todo.id} todo={todo} on_toggle={...} on_remove={...} />Without
:key, Filament matches children by position; removing an item in the middle shifts all subsequent fibers. With:key={todo.id}, Filament matches by identity and cleanly unmounts only the removed item.
Testing with Filament.Test
Filament's rung-2 API mounts a component tree in-process with no WebSocket
required. Tests run with async: true and complete in milliseconds.
Here are the rung-2 tests from examples/todo/test/todo_test.exs:
defmodule Todo.Test do
use ExUnit.Case, async: true
import Filament.Test
describe "TodoList component" do
test "renders empty state on first mount" do
{:ok, view} = mount(TodoWeb.Components.TodoList, %{})
refute view.rendered_html =~ "<li"
end
test "renders todo items after submission" do
{:ok, view} = mount(TodoWeb.Components.TodoList, %{})
{:ok, view} = submit(view, "form", %{"text" => "First task"})
{:ok, view} = submit(view, "form", %{"text" => "Second task"})
text = render_text(view)
assert text =~ "First task"
assert text =~ "Second task"
end
test "toggling a todo marks it completed" do
{:ok, view} = mount(TodoWeb.Components.TodoList, %{})
{:ok, view} = submit(view, "form", %{"text" => "Done task"})
{:ok, view} = click(view, ".todo-list li input[type=checkbox]")
assert view.rendered_html =~ "class=\"completed"
end
end
endKey API functions:
mount(Component, props)— renders the component in isolation and returns{:ok, view}. Theviewstruct holdsrendered_htmland a livefiber_tree.render_text(view)— strips HTML tags and returns plain text, useful for content assertions.click(view, selector)— fires theon_clickhandler on the first element matching the CSS selector and returns{:ok, updated_view}.submit(view, selector, params)— fires a form submit handler with the given params map.
Because rung-2 tests use in-process message passing rather than a browser,
they are safe to run with async: true and typically finish in under 5 ms.
Next steps
- Observables guide — learn
Observable.GenServer,use_observable/1anduse_observable/2, and the change-or-bust pattern for efficient re-renders. - Hooks guide — composing built-in hooks and writing custom hooks for domain logic.
- API reference — see
Filament.Hooksfor the full hook signatures andFilament.Componentfor the behaviour callbacks.