Filament - A reactive layer on top of live view

Filament – React-style hooks and observable GenServers for Phoenix LiveView

I just published Filament, a component and state-management layer for Phoenix LiveView that brings a few ideas from React into Elixir — without the JavaScript.

The problem it solves

As LiveView apps grow, state tends to scatter: socket assigns accumulate, handle_event callbacks multiply, and PubSub wiring ends up duplicated across LiveViews. When multiple components care about the same GenServer, you end up with a lot of boilerplate to subscribe, receive, and broadcast updates.

Filament gives you a cleaner model: isolated component fibers, hook-based local state, and observable GenServers that components subscribe to directly.

What it looks like

defmodule CartWeb.Components.CartBadge do
  use Filament.Component

  defcomponent do
    prop(:cart_id, :string, required: true)

    def render(%{cart_id: cart_id}) do
      # subscribes to the GenServer; re-renders only when item count changes
      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
end

use_observable/2 subscribes to any Observable.GenServer. When the server calls notify_observers(new_state), every subscribed component re-renders — no handle_info, no PubSub wiring in the LiveView. The projection fn extracts only the slice the component cares about; if the projected value is unchanged, the re-render is suppressed.

Key features

  • ~F templates — JSX-like sigil with {expression} interpolation, {for … do} loops, and <MyComponent prop={value} /> child tags
  • defcomponent — typed, validated props; each instance gets an isolated fiber with its own hook state and event handlers
  • use_state/1 — local mutable state that re-renders only the affected fiber, never the whole LiveView
  • Observable GenServers — wrap any GenServer with use Filament.Observable.GenServer; components subscribe with use_observable/2
  • Projections + change-or-bust — projection fns run at render time and can close over local component state; stable projected values suppress re-renders automatically
  • Automatic memoization — the ~F compiler wraps closures and child renders in memo_at calls; stable subtrees skip re-evaluation without any annotation
  • Composable custom hooks — any function that calls use_state, use_observable, or use_effect is a hook; domain logic lives in plain module functions
  • use_effect/2 — side effects with dependency tracking and cleanup
  • Static render with seamless handoff — subscriptions run during the initial HTTP render so pages arrive with real data already in the HTML; the WebSocket connection inherits the existing subscription without re-fetching
  • Incremental adoption — drop a Filament tree into an existing LiveView with Filament.LiveComponent; no big-bang rewrite required
  • Fast, in-process tests — mount and interact with component trees without a browser or WebSocket; tests run async: true in milliseconds

Getting started

{:filament, "~> 0.2"}

There are four example apps in the repo (todo, cart, inventory, collaboration) and guides covering getting started, observables, hooks, and incremental migration.

Early days — feedback very welcome.

6 Likes