Migration Guide
Copy MarkdownThis guide is for teams with an existing Phoenix LiveView application who want to
adopt Filament incrementally. Filament is not all-or-nothing — you can migrate one
LiveView at a time using Filament.LiveComponent as an entry point, and promote to
a full Filament LiveView only when you are ready.
Prerequisite reading: Getting Started and Observables.
Phase 1: Add Filament to your project
Add the dependency:
# mix.exs
{:filament, "~> 0.1"}Run mix deps.get. No other changes to your existing LiveViews are required yet.
Phase 2: Identify your assigns
Start by reading your existing LiveView and classifying each assign by its intended role. Here is a representative before-state:
defmodule MyApp.CartLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, items: [], total: 0)}
end
def handle_event("add_item", %{"id" => id, "name" => name, "price" => price}, socket) do
item = %{id: id, name: name, price: String.to_integer(price)}
items = socket.assigns.items ++ [item]
total = Enum.sum(Enum.map(items, & &1.price))
{:noreply, assign(socket, items: items, total: total)}
end
def render(assigns) do
~H"""
<ul>
<%= for item <- @items do %>
<li><%= item.name %> — <%= item.price %></li>
<% end %>
</ul>
<p>Total: <%= @total %></p>
"""
end
endClassify each assign:
| Assign | Role | Target |
|---|---|---|
items, total | Shared domain state | Observable.GenServer |
| Form input, filter | Ephemeral UI state | use_state in component |
| Checkout lock, seat hold | Resource claim | Custom hook (see Hooks guide) |
| Derived totals, counts | Computed from domain | Computed in server |
Phase 3: Extract domain state into an Observable.GenServer
Move items and total out of the socket and into a GenServer:
defmodule MyApp.CartServer do
use Filament.Observable.GenServer
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, %{items: [], total: 0}, name: name)
end
@impl GenServer
def init(state), do: {:ok, state}
@impl Filament.Observable
def handle_subscribe(_subscriber, state), do: {:ok, state, state}
@impl GenServer
def handle_call({:add_item, item}, _from, state) do
items = state.items ++ [item]
total = Enum.sum(Enum.map(items, & &1.price))
new_state = %{state | items: items, total: total}
notify_observers(new_state)
{:reply, :ok, new_state}
end
endStart the server in your application supervisor:
# application.ex
children = [MyApp.CartServer, ...]See the Observables guide for the full Observable.GenServer
explanation including projections and the change-or-bust mechanism.
Phase 4: Write the Filament component
Replace the assigns-based render logic with a Filament component that subscribes to the GenServer:
defmodule MyApp.CartComponent do
use Filament.Component
defcomponent do
prop(:server, :any, default: MyApp.CartServer)
def render(%{server: server}) do
cart = use_observable(server, fn :disconnected -> nil; s -> s end)
items = if cart == nil, do: [], else: cart.items
total = if cart == nil, do: 0, else: cart.total
~F"""
<div>
<ul>
{for item <- items do}
<li>{item.name} — {item.price}</li>
{end}
</ul>
<p>Total: {total}</p>
</div>
"""
end
end
endKey differences from the LiveView template:
use_observable(server, fn :disconnected -> nil; s -> s end)subscribes this component to the server and returns the current projected value. The function receives:disconnectedduring HTTP renders and returns a safe default.~F"""templates use{expression}interpolation and{for ... do}/{end}loops instead of<%= %>and<% %>.- The component re-renders automatically when
notify_observers/1is called on the server — you do not needhandle_eventto update the view.
Server-as-prop: sharing one server across sibling components
When a parent renders multiple children that all project from the same server, resolve
the server once in the parent and pass the pid as a prop. This keeps subscriptions
efficient — each {parent_pid, request} pair shares a single subscriber entry on the
server regardless of how many projections it has:
# Parent: resolve once with use_observable/1
def render(%{session_id: session_id}) do
server = use_observable(fn -> MyApp.CartServer.start_link(session_id) end)
~F"""
<CartBadge server={server} />
<CartItems server={server} />
"""
end
# Child: project with use_observable/2 — no redundant subscription
def render(%{server: server}) do
count = use_observable(server, fn :disconnected -> 0; s -> Cart.State.item_count(s) end)
...
endPhase 5: Embed in the existing LiveView (incremental adoption)
You do not have to rewrite the whole LiveView at once. Embed the Filament component
using Filament.LiveComponent:
<%!-- In your existing LiveView template: --%>
<.live_component
module={Filament.LiveComponent}
id="cart"
component={MyApp.CartComponent}
/>Because Filament.LiveComponent runs inside the parent LiveView process, observable
update messages must be forwarded from the parent's handle_info/2:
# In MyApp.CartLive:
def handle_info({type, _} = msg, socket)
when type in [:filament_set_state, :filament_observable_updates,
:filament_observable_resubscribe] do
Phoenix.LiveView.send_update(Filament.LiveComponent, id: "cart", filament_msg: msg)
{:noreply, socket}
endThis is a Phase 1 limitation described in Filament.LiveComponent. You only need
this forwarding while the component is hosted inside a regular LiveView.
For components that use only use_state (no use_observable), no forwarding is
needed because state updates are handled internally within the same process.
Phase 6: Full migration (optional)
Once the entire LiveView template is replaced with Filament components, switch from
the incremental adapter to Filament.LiveView:
defmodule MyApp.CartLive do
use Filament.LiveView
def root_component, do: MyApp.CartComponent
endThis eliminates the need for the handle_info forwarding pattern — Filament.LiveView
handles all internal messages automatically. See the Getting Started guide
for the full Filament.LiveView explanation.
Phase 7: Add resource holds (if needed)
If your application has checkout flows, pessimistic locks, or reservation UX, holds
are not part of Filament core — but they are straightforward to build as a custom
hook on top of use_observable. See
examples/inventory/lib/inventory_web/hooks.ex for a complete use_hold/3
implementation that acquires and releases quantity-based holds, with automatic
release when the LiveView disconnects via handle_unsubscribe/2 on the server.
The Hooks guide covers composing and writing custom hooks.
Observable API breaking changes
This section documents the breaking changes to the Observable/subscription API introduced after the initial 0.1 release. If you are starting fresh from the current release you can skip this section — the examples in Phases 3–6 above already reflect the new API.
use_projection/3 removed — use use_observable/2 instead
use_projection/3 no longer exists. Replace every call with use_observable/2,
passing a two-clause function that handles the :disconnected case and projects
the live state. The function runs client-side at render time and can close over
local component assigns.
# Before
server = use_observable(CartServer)
count = use_projection(server, fn state -> Cart.State.item_count(state) end, disconnected: 0)
# After
count = use_observable(CartServer, fn
:disconnected -> 0
state -> Cart.State.item_count(state)
end)The old disconnected: keyword option form of use_observable/2 is also gone —
use the function-argument form shown above for all cases.
handle_subscribe/3 → handle_subscribe/2
The request argument has been removed from the handle_subscribe callback.
Drop the first parameter:
# Before
def handle_subscribe(_request, _subscriber, state), do: {:ok, state, state}
# After
def handle_subscribe(_subscriber, state), do: {:ok, state, state}Observable.subscribe/3 → Observable.subscribe/2
The request argument has been dropped from Observable.subscribe/3. If you
call this function directly (e.g. in tests or low-level integration code), remove
the second positional argument:
# Before
Observable.subscribe(server, nil, subscriber)
# After
Observable.subscribe(server, subscriber)Observable.remove_projection/5 is now Observable.remove_projection/4 for the
same reason — the request argument is gone.
Subscriber struct fields
Subscriber.request and Subscriber.projections have been removed. The
replacement for tracking active projections is proj_keys, a map of
{fiber_id, slot_index} tuples to true:
# Before
%Subscriber{pid: self(), request: nil, projections: %{{"root", 0} => {& &1, :unset}}}
# After
%Subscriber{pid: self(), proj_keys: %{{"root", 0} => true}}{:subscribed, ...} slot shape
The four-element {:subscribed, server, request, projected_value} tuple is gone.
If you pattern-match on this shape anywhere, remove the request element.
Change-detection: raw state, client-side projection
Previously the server compared projected values to decide whether to push an
update. The server now sends raw state to all subscribers and each subscriber
compares the raw value (new_raw !== last_raw) independently. Projection
functions are applied at render time on the client side. The practical effect:
- The projection function passed to
use_observable/2can safely close over component-local state without needing the server to know about it. - All subscribers to the same server share one raw-state broadcast; there is no per-projection diffing on the server.
Codemods (where automatable)
Several transformations follow a mechanical pattern that could be automated:
assign(socket, key: value)inhandle_event→use_statein the componentsocket.assigns.keyin render → bind the variable inrender/1pattern matchsocket.assigns.keyin templates →{variable}or{@key}in~Ftemplateshandle_event("name", params, socket)→on_*closure in the template; state updates via captureduse_statesetters, side effects via captured server refs
These are not shipped as codemod tools in this release. The patterns are regular enough that a Sourceror-based transform could automate most of them. Community contributions are welcome — the Getting Started guide provides the target syntax.
What does NOT need to change
- Routing, controllers, and non-LiveView code: untouched.
- Existing LiveViews that are not being migrated: leave them alone.
- Phoenix layout files and
root.html.heex: no changes required. - Test infrastructure: Filament's rung-2 test API is additive; you keep your
existing
Phoenix.LiveViewTesttests for non-Filament LiveViews.