Migration Guide

Copy Markdown

This 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
end

Classify each assign:

AssignRoleTarget
items, totalShared domain stateObservable.GenServer
Form input, filterEphemeral UI stateuse_state in component
Checkout lock, seat holdResource claimCustom hook (see Hooks guide)
Derived totals, countsComputed from domainComputed 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
end

Start 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
end

Key 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 :disconnected during 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/1 is called on the server — you do not need handle_event to 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)
  ...
end

Phase 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}
end

This 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
end

This 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/3handle_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/3Observable.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/2 can 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) in handle_eventuse_state in the component
  • socket.assigns.key in render → bind the variable in render/1 pattern match
  • socket.assigns.key in templates → {variable} or {@key} in ~F templates
  • handle_event("name", params, socket)on_* closure in the template; state updates via captured use_state setters, 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.LiveViewTest tests for non-Filament LiveViews.