PhoenixDatastar

Hex.pmDocumentation

A LiveView-like experience for Phoenix using Datastar's SSE + Signals architecture.

This is still in alpha, I'm figuring out the right apis. Comments and ideas welcome.

Build interactive Phoenix applications with Datastar's simplicity: SSE instead of WebSockets, hypermedia over JSON, and a focus on performance.

Installation

With Igniter

If you have Igniter installed, run:

mix igniter.install phoenix_datastar

This will automatically:

You'll then just need to add your routes (the installer will show you instructions).

Manual Installation

Add phoenix_datastar to your list of dependencies in mix.exs:

def deps do
  [
    {:phoenix_datastar, "~> 0.1.16"}
  ]
end

Then follow the setup steps below.

1. Add Datastar to your layout

Include the Datastar JavaScript library in your layout's <head>:

<script
  type="module"
  src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"
></script>

2. Add to your supervision tree

In your application.ex:

children = [
  # ... other children
  {Registry, keys: :unique, name: PhoenixDatastar.Registry},
  # ... rest of your children
]

3. Import the router macro

In your router:

import PhoenixDatastar.Router

scope "/", MyAppWeb do
  pipe_through :browser

  datastar "/counter", CounterStar
end

For session-aware live navigation, add global Datastar endpoints and group routes with datastar_session. The /__datastar scope is added automatically by mix phoenix_datastar.install:

import PhoenixDatastar.Router

# Global Datastar endpoints for SSE streaming and soft navigation.
# These need session access for CSRF protection.
scope "/__datastar" do
  pipe_through [:fetch_session, :protect_from_forgery]
  get "/stream", PhoenixDatastar.StreamPlug, :stream
  post "/nav", PhoenixDatastar.NavPlug, :navigate
end

scope "/", MyAppWeb do
  pipe_through [:browser, :require_user]

  datastar_session :dashboard,
    root_selector: "#dashboard-root" do
    datastar "/dashboard", DashboardStar
    datastar "/dashboard/orgs", DashboardOrgsStar
  end
end

4. Create :live_datastar and :datastar in your _web.ex

defmodule MyAppWeb do
#... existing calls

  def live_datastar do
    quote do
      use PhoenixDatastar, :live
      import PhoenixDatastar.Actions

      unquote(html_helpers())
    end
  end

  def datastar do
    quote do
      use PhoenixDatastar
      import PhoenixDatastar.Actions

      unquote(html_helpers())
    end
  end
end

5. Strip debug annotations in dev (optional)

In your config/dev.exs, enable stripping of LiveView debug annotations from SSE patches:

config :phoenix_datastar, :strip_debug_annotations, true

comments anddata-phx-locattributes from SSE patches. The initial page load keeps annotations intact for debugging. #### 6. Customize the mount template (optional) PhoenixDatastar ships with a built-in mount template (PhoenixDatastar.DefaultHTML) that wraps your view content with the necessary Datastar signals and SSE initialization. **You don't need to create your own** — it works out of the box. The default template automatically: - Injectssession_idas a Datastar signal - Initializes all signals set viaput_signalinmount/3as Datastar signals (via@initial_signals) - Sets up the SSE stream connection for live views If you need to customize it (e.g., add classes, extra attributes, or additional markup), create your own module: ```elixir defmodule MyAppWeb.DatastarHTML do use Phoenix.Component def mount(assigns) do ~H""" <div id="app" class="my-wrapper" data-signals={ Jason.encode!( Map.merge(@initial_signals, %{ session_id: @session_id, event_path: @event_path, nav_path: @nav_path, nav_token: @nav_token }) ) } data-init__once={@stream_path && "@get('#{@stream_path}', {openWhenHidden: true})"} > {@inner_html} </div> """ end end ``` Available assigns in the mount template: -@session_id— unique session identifier -@initial_signals— map of signals set viaput_signalinmount/3-@stream_path— SSE stream URL (nil for stateless views) -@event_path— event POST URL -@nav_path— soft navigation POST URL -@nav_token— signed stream/nav token -@inner_html— the rendered view content Then configure it inconfig/config.exs: ```elixir config :phoenix_datastar, :html_module, MyAppWeb.DatastarHTML ``` Or per-route: ```elixir datastar "/custom", CustomStar, html_module: MyAppWeb.DatastarHTML ``` ## Usage ### Assigns vs Signals PhoenixDatastar separates server-side state from client-side reactive state: - **Assigns** (assign/2,3,update/3) are server-side state. They are available in templates as@keyand are **never sent to the client**. Use them for structs, DB records, or any data the server needs to remember or render HTML with. - **Signals** (put_signal/2,3,update_signal/3) are Datastar reactive state sent to the client via SSE. They must be JSON-serializable. The client accesses them via Datastar expressions like$count. Signals are **not** available as@keyin templates — Datastar handles their rendering client-side. Client signals arrive as thepayloadargument inhandle_event/3. They are untrusted input — read, validate, and explicitlyput_signalwhat you want to send back. ### Basic Example: Signals The simplest pattern uses Datastar signals for all client state. The count lives entirely in signals — Datastar renders it client-side viadata-text="$count": ```elixir defmodule MyAppWeb.CounterStar do use MyAppWeb, :datastar # or: use PhoenixDatastar @impl PhoenixDatastar def mount(_params, _session, socket) do {:ok, put_signal(socket, :count, 0)} end @impl PhoenixDatastar def handle_event("increment", payload, socket) do count = payload["count"] || 0 {:noreply, put_signal(socket, :count, count + 1)} end @impl PhoenixDatastar def render(assigns) do ~H""" <div> Count: <span data-text="$count"></span> <button data-on:click={event("increment")}>+</button> </div> """ end end ``` ### Server-Rendered Patches with Assigns For more complex rendering, use **assigns** for server-side state and **patch_elements** to push HTML updates. This is useful when you need HEEx templates, loops, or conditional logic that's easier to express server-side: ```elixir defmodule MyAppWeb.ItemsStar do use MyAppWeb, :live_datastar # or: use PhoenixDatastar, :live @impl PhoenixDatastar def mount(_params, _session, socket) do {:ok, assign(socket, items: ["Alpha", "Bravo"])} end @impl PhoenixDatastar def handle_event("add", %{"name" => name}, socket) do {:noreply, socket |> update(:items, &(&1 ++ [name])) |> patch_elements("#items", &render_items/1)} end @impl PhoenixDatastar def render(assigns) do ~H""" <div> <ul id="items"> <li :for={item <- @items}>{item}</li> </ul> <button data-on:click={event("add", "name: $newItem")}>Add</button> </div> """ end defp render_items(assigns) do ~H""" <ul id="items"> <li :for={item <- @items}>{item}</li> </ul> """ end end ``` > **Tip:** You can combine both patterns — useput_signalfor simple reactive values > (toggles, counters, form inputs) andassign+patch_elementsfor complex > server-rendered sections. ## The Lifecycle PhoenixDatastar uses a hybrid of request/response and streaming: 1. **Initial Page Load (HTTP)**:GET /countercallsmount/3andrender/1, returns full HTML 2. **SSE Connection**:GET /datastar/stream?token=...opens a persistent connection, starts or reuses a GenServer (live views only) 3. **User Interactions**:POST /counter/_event/:eventtriggershandle_event/3, updates pushed via SSE (live) or returned directly (stateless) ### Session Navigation (alpha) When routes are grouped under the samedatastar_session, you can navigate between them without a full page reload: 1. Client clicks a<.ds_link>or callsnavigate("/path"). 2.POST /datastar/navis sent with the signednav_token(included automatically by Datastar as a signal). 3.PhoenixDatastar.NavPlugverifies the token, matches the target route viaPhoenixDatastar.RouteRegistry, and checks the target is a live view in the same session. 4. If valid:Server.navigate/5swaps the view in the existing GenServer, pushes new HTML + signals +pushStatethrough the SSE stream. A freshnav_tokenis issued. 5. If invalid (different session, stateless target, or unknown route): falls back towindow.locationfor a full page reload. **Note:** Soft navigation only works between live views (use PhoenixDatastar, :live) within the samedatastar_session. Stateless views always trigger a full page reload. #### Key Modules - **PhoenixDatastar.StreamPlug** — HandlesGET /datastar/stream?token=.... Verifies the stream token, subscribes to the session's GenServer, and enters the SSE loop. - **PhoenixDatastar.NavPlug** — HandlesPOST /datastar/nav. Verifies the nav token, matches the target route, and either performs soft navigation or falls back to a full reload. - **StreamToken** — Signs and verifies Phoenix tokens for stream/nav authorization. Token expiry defaults to 1 hour, configurable viaconfig :phoenix_datastar, :stream_token_max_age, 3600. - **PhoenixDatastar.RouteRegistry** — Runtime route lookup using metadata compiled bydatastar/3. ## Callbacks | Callback | Purpose | |----------|---------| |mount/3| Initialize state on page load | |handle_event/3| React to user actions | |handle_info/2| Handle PubSub messages, timers, etc. | |render/1| Render the full component | |terminate/1| Cleanup on disconnect (optional) | ## Socket API ### Assigns (server-side state) ```elixir # Assign values (server-side only, available as @key in templates) socket = assign(socket, :user, current_user) socket = assign(socket, items: [], loading: true) # Update with a function socket = update(socket, :count, &(&1 + 1)) ``` ### Signals (client-side Datastar state) ```elixir # Set signals (sent to client, accessed as $key in Datastar expressions) socket = put_signal(socket, :count, 0) socket = put_signal(socket, count: 0, name: "test") # Update a signal with a function socket = update_signal(socket, :count, &(&1 + 1)) ``` ### DOM Patches ```elixir # Queue a DOM patch (sent via SSE) socket = patch_elements(socket, "#selector", &render_fn/1) socket = patch_elements(socket, "#selector", ~H|<span>html</span>|) ``` ### Scripts and Navigation ```elixir # Execute JavaScript on the client socket = execute_script(socket, "alert('Hello!')") socket = execute_script(socket, "console.log('debug')", auto_remove: false) # Redirect the client socket = redirect(socket, "/dashboard") # Log to the browser console socket = console_log(socket, "Debug message") socket = console_log(socket, "Warning!", level: :warn) ``` ## Action Helpers PhoenixDatastar provides helper functions to simplify generating Datastar action expressions in your templates. ### Requirements - A<meta name="csrf-token">tag must be present in your layout (Phoenix includes this by default) ###event/1,2Generates a Datastar@postaction for triggering server events. The generated expression uses$session_idand$event_pathDatastar signals (automatically initialized byDefaultHTML), so it works in any component without needing to pass framework assigns through. ```elixir # Simple event <button data-on:click={event("increment")}>+1</button> # Event with options <button data-on:click={event("toggle_code", "name: 'counter'")}>Toggle</button> # With signals <button data-on:click={event("update", "value: $count")}>Update</button> ``` ###navigate/1,2and<.ds_link>Generates a Datastar@postexpression for in-session soft navigation. The$nav_tokensignal is automatically included by Datastar in the request — no manual setup required. ```elixir <button data-on:click={navigate("/dashboard/orgs")}>Go to orgs</button> <button data-on:click={navigate("/dashboard/orgs", replace: true)}>Replace history</button> ```<.ds_link>wraps this in an anchor tag with a normalhreffallback for accessibility, right-click, and modified clicks (Ctrl/Cmd+click open in new tab): ```elixir <.ds_link navigate="/dashboard/orgs">Organizations</.ds_link> <.ds_link navigate="/dashboard/orgs" replace>Organizations</.ds_link> <.ds_link navigate="/other" method={:hard}>Full Reload</.ds_link> ``` The:methodattribute (:softor:hard, default:soft) lets you force a full page navigation when needed (e.g., switching workspaces). ## Stateless vs Live Views ```elixir # Stateless view - no persistent connection, events handled synchronously use MyAppWeb, :datastar # or: use PhoenixDatastar # Live view - persistent SSE connection with GenServer state use MyAppWeb, :live_datastar # or: use PhoenixDatastar, :live ``` **Stateless views** handle events synchronously — state is restored by callingmount/3on each request, client signals arrive in the payload, and the response is returned immediately. No GenServer or SSE connection is maintained. **Live views** maintain a GenServer and SSE connection. Use:livewhen you need: - Real-time updates from the server (PubSub, timers) - Persistent server-side state across interactions -handle_info/2callbacks ## Tips ### Showing Flash Messages Phoenix's built-in flash system (put_flash/3) doesn't work with PhoenixDatastar since there's no LiveView process managing flash state. Instead, useassignto set flash data andpatch_elementsto render the flash group component from your layout: ```elixir def handle_event("save", _payload, socket) do # ... save logic ... socket = assign(socket, flash: %{"info" => "Saved successfully!"}) {:noreply, patch_elements(socket, "#flash-group", &render_flash_group/1)} end def handle_info(:show_flash, socket) do socket = assign(socket, flash: %{"info" => "hello world"}) {:noreply, patch_elements(socket, "#flash-group", &render_flash_group/1)} end defp render_flash_group(assigns) do ~H""" <Layouts.flash_group flash={@flash} /> """ end ``` Make sure your layout's flash group has the#flash-group` ID so the patch selector can target it. ## Links - Datastar - The frontend library this integrates with - Phoenix LiveView - The inspiration for the callback design