Migrating from PhoenixDatastar to Dstar

Copy Markdown View Source

This guide walks you through migrating an existing PhoenixDatastar application to Dstar.

Why Migrate?

PhoenixDatastar is deprecated as of v0.2.0 — no further updates are planned. Dstar is its successor, built on the same SSE/Datastar foundations but with a radically simpler architecture:

PhoenixDatastarDstar
ArchitectureBehaviours, GenServers, Socket structs, Registry, supervision trees~700 lines of pure functions
StateSocket with assigns + signals + event queueDirect Plug.Conn — no wrapper structs
RoutingCustom macros (datastar_session, datastar)Standard Phoenix routes
ViewsStateless + Live view modes via use macroPlain controller actions
Real-timeBuilt-in GenServer per sessionPubSub + receive loop (you own it)
NavigationSoft nav system (NavPlug, RouteRegistry, tokens)Standard links / Datastar client-side nav
DependenciesPhoenix (full framework)Plug + Jason only
Configconfig :phoenix_datastar, Registry in supervision treeNothing — zero config

The tradeoff is clear: PhoenixDatastar gave you more batteries (session management, soft navigation, process-backed state). Dstar gives you raw primitives and gets out of your way. You write less library code and more application code.


Step 1: Update Dependencies

# mix.exs

# Before
def deps do
  [
    {:phoenix_datastar, "~> 0.2.0"}
  ]
end

# After
def deps do
  [
    {:dstar, "~> 0.0.7"}
  ]
end

Run mix deps.get.

Step 2: Remove PhoenixDatastar Infrastructure

Supervision tree

Remove the Registry from your application.ex:

# Before — application.ex
children = [
  {Registry, keys: :unique, name: PhoenixDatastar.Registry},
  # ...
]

# After — just remove the Registry line
children = [
  # ...
]

Configuration

Remove all config :phoenix_datastar entries from config/:

# Before — config.exs
config :phoenix_datastar,
  html_module: MyAppWeb.DatastarHTML,
  strip_debug_annotations: true,
  stream_token_max_age: 3600

# After — delete these lines entirely. Dstar has no config.

Web module helpers

Remove the datastar and live_datastar helper functions from your *_web.ex:

# Before — my_app_web.ex
def datastar do
  quote do
    use PhoenixDatastar
    import PhoenixDatastar.Actions
    unquote(html_helpers())
  end
end

def live_datastar do
  quote do
    use PhoenixDatastar, :live
    import PhoenixDatastar.Actions
    unquote(html_helpers())
  end
end

# After — delete both functions. You won't need them.

Step 3: Rewrite Routes

PhoenixDatastar used custom router macros. Dstar uses standard Phoenix routes.

Basic routes

# Before — router.ex
import PhoenixDatastar.Router

scope "/__datastar" do
  pipe_through [:fetch_session]
  post "/stream", PhoenixDatastar.StreamPlug, :stream
end

scope "/__datastar" do
  pipe_through [:fetch_session, :protect_from_forgery]
  post "/nav", PhoenixDatastar.NavPlug, :navigate
end

scope "/", MyAppWeb do
  pipe_through :browser

  datastar_session :default do
    datastar "/counter", CounterStar
    datastar "/todos", TodoStar
  end
end

# After — router.ex
scope "/", MyAppWeb do
  pipe_through :browser

  get "/counter", CounterController, :show
  post "/counter/increment", CounterController, :increment

  get "/todos", TodoController, :show
  post "/todos/add", TodoController, :add
  post "/todos/toggle", TodoController, :toggle
end

The /__datastar/* routes (stream, nav) are no longer needed — there are no session processes or soft navigation tokens.

Using dynamic dispatch (optional)

If you have many events per view, you can use Dstar.Plugs.Dispatch instead of individual routes:

# After (alternative) — router.ex
scope "/", MyAppWeb do
  pipe_through :browser

  get "/counter", CounterController, :show
  get "/todos", TodoController, :show
end

# Single route handles all Datastar events
post "/ds/:module/:event", Dstar.Plugs.Dispatch, modules: [
  MyAppWeb.CounterHandler,
  MyAppWeb.TodoHandler
]

Step 4: Convert Views to Controllers

This is the biggest change. PhoenixDatastar views are behaviour modules with callbacks. Dstar uses plain controller actions (or handler modules with handle_event/3).

Stateless views

# Before — counter_star.ex
defmodule MyAppWeb.CounterStar do
  use MyAppWeb, :datastar

  @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) + 1
    {: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
# After — counter_controller.ex
defmodule MyAppWeb.CounterController do
  use MyAppWeb, :controller

  def show(conn, _params) do
    render(conn, :counter)
  end

  def increment(conn, _params) do
    signals = Dstar.read_signals(conn)
    count = (signals["count"] || 0) + 1

    conn
    |> Dstar.start()
    |> Dstar.patch_signals(%{count: count})
  end
end
<%!-- After — counter.html.heex --%>
<div data-signals:count="0">
  Count: <span data-text="$count"></span>
  <button data-on:click="@post('/counter/increment')">+</button>
</div>

Key differences:

  • No mount/3 — initial state goes in the template via data-signals
  • No socket — work directly with conn
  • No {:noreply, socket} tuples — just return the conn
  • No put_signal/3 — use Dstar.patch_signals/3
  • No render/1 callback — use standard Phoenix templates
  • No event("name") — use @post('/path') or Dstar.post(Module, "name")

Stateless views using dynamic dispatch

If you prefer handler modules over controller actions (closer to the PhoenixDatastar pattern):

# After (alternative) — counter_handler.ex
defmodule MyAppWeb.CounterHandler do
  def handle_event(conn, "increment", signals) do
    count = (signals["count"] || 0) + 1

    conn
    |> Dstar.start()
    |> Dstar.patch_signals(%{count: count})
  end
end
<%!-- Template uses Dstar.post/2 for dispatch routing --%>
<div data-signals:count="0">
  Count: <span data-text="$count"></span>
  <button data-on:click={Dstar.post(MyAppWeb.CounterHandler, "increment")}>+</button>
</div>

Note: The handle_event/3 signature is different — Dstar passes (conn, event, signals) instead of PhoenixDatastar's (event, payload, socket).

Live views (with PubSub)

Live views require the most thought. PhoenixDatastar managed a GenServer per session with persistent SSE. In Dstar, you manage the SSE loop yourself.

# Before — multiplayer_star.ex
defmodule MyAppWeb.MultiplayerStar do
  use MyAppWeb, :live_datastar

  @impl PhoenixDatastar
  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "game:updates")
    {:ok, assign(socket, players: [])}
  end

  @impl PhoenixDatastar
  def handle_event("join", %{"name" => name}, socket) do
    Phoenix.PubSub.broadcast(MyApp.PubSub, "game:updates", {:player_joined, name})
    {:noreply, update(socket, :players, &[name | &1])}
  end

  @impl PhoenixDatastar
  def handle_info({:player_joined, name}, socket) do
    socket = update(socket, :players, &[name | &1])
    {:noreply, patch_elements(socket, "#players", &render_players/1)}
  end

  @impl PhoenixDatastar
  def render(assigns) do
    ~H"""
    <div>
      <div id="players"><.render_players players={@players} /></div>
      <button data-on:click={event("join")}>Join</button>
    </div>
    """
  end

  defp render_players(assigns) do
    ~H"<ul><li :for={p <- @players}>{p}</li></ul>"
  end
end
# After — split into controller + stream controller

# game_controller.ex (page load + events)
defmodule MyAppWeb.GameController do
  use MyAppWeb, :controller

  def show(conn, _params) do
    render(conn, :game)
  end

  def join(conn, _params) do
    signals = Dstar.read_signals(conn)
    name = signals["name"]
    Phoenix.PubSub.broadcast(MyApp.PubSub, "game:updates", {:player_joined, name})

    conn
    |> Dstar.start()
    |> Dstar.patch_signals(%{joined: true})
  end
end

# game_stream_controller.ex (persistent SSE connection)
defmodule MyAppWeb.GameStreamController do
  use MyAppWeb, :controller

  def stream(conn, _params) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "game:updates")

    conn
    |> Dstar.start()
    |> loop()
  end

  defp loop(conn) do
    receive do
      {:player_joined, name} ->
        html = Phoenix.Template.render_to_string(MyAppWeb.GameHTML, "players", %{name: name})

        conn
        |> Dstar.patch_elements(html, selector: "#players ul", mode: :append)
        |> loop()
    end
  end
end
<%!-- game.html.heex --%>
<div data-signals:name="''"
     data-signals:joined="false"
     data-init="@post('/game/stream', {retryMaxCount: Infinity})"
     data-on:online__window="@post('/game/stream', {retryMaxCount: Infinity})">

  <div id="players"><ul></ul></div>

  <input data-bind:name placeholder="Your name" />
  <button data-on:click="@post('/game/join')">Join</button>
</div>
# Routes
get "/game", GameController, :show
post "/game/join", GameController, :join
post "/game/stream", GameStreamController, :stream

Key differences:

  • No GenServer — the stream action is the long-lived process
  • No handle_info/2 callback — use a receive loop directly
  • No automatic keepalive — Datastar's retryMaxCount: Infinity handles reconnection
  • No session management — each stream connection is independent
  • assigns (server-side state) don't survive across requests — use the database, ETS, or PubSub to share state

Step 5: Update Templates

Signals initialization

<%!-- Before: signals were set in mount/3 via put_signal --%>
<%!-- The template just referenced them with $signal_name --%>

<%!-- After: initialize signals directly in the template --%>
<div data-signals:count="0"
     data-signals:name="''"
     data-signals:items="[]">
  ...
</div>

Event bindings

<%!-- Before --%>
<button data-on:click={event("increment")}>+</button>
<button data-on:click={event("save", method: :put)}>Save</button>

<%!-- After — explicit routes --%>
<button data-on:click="@post('/counter/increment')">+</button>
<button data-on:click="@put('/items/save')">Save</button>

<%!-- After — using dynamic dispatch --%>
<button data-on:click={Dstar.post(MyAppWeb.CounterHandler, "increment")}>+</button>
<%!-- Before — soft navigation --%>
<.ds_link navigate="/settings">Settings</.ds_link>
<button data-on:click={navigate("/settings")}>Go</button>

<%!-- After — standard links (full page load) --%>
<a href="/settings">Settings</a>

<%!-- After — Datastar client-side nav (if you want SPA-like behavior) --%>
<%!-- See Datastar docs for data-on:click with fetch + DOM swap patterns --%>

Note: Dstar does not include a soft navigation system. If you relied heavily on PhoenixDatastar's in-session navigation, you'll need to either accept full page loads or implement client-side navigation yourself using Datastar's primitives.

Server-rendered patches

# Before — patch_elements on socket with render function
def handle_event("refresh", _payload, socket) do
  {:noreply, patch_elements(socket, "#items", &render_items/1)}
end

defp render_items(assigns) do
  ~H"<ul id='items'><li :for={i <- @items}>{i}</li></ul>"
end

# After — patch_elements on conn with HTML string
def refresh(conn, _params) do
  items = MyApp.Items.list()
  html = Phoenix.Template.render_to_string(MyAppWeb.ItemHTML, "list", %{items: items})

  conn
  |> Dstar.start()
  |> Dstar.patch_elements(html, selector: "#items")
end

Step 6: Update CSRF Setup

<%!-- Before — Phoenix already exposes this in the layout head --%>
<meta name="csrf-token" content={get_csrf_token()} />

<%!-- After — keep using the standard Phoenix meta tag --%>
<meta name="csrf-token" content={get_csrf_token()} />

When using Dstar's verb helpers (post/2,3, get/2,3, put/2,3, patch/2,3, delete/2,3), the CSRF header is automatically included in the generated expressions by reading the <meta name="csrf-token"> tag.

This means Datastar's normal signal round-tripping does not rewrite the CSRF header used by Dstar.Actions.

If you write @post(...) manually, add the header yourself:

<button data-on:click="@post('/counter/increment', {headers: {'x-csrf-token': document.querySelector('meta[name=csrf-token]').content}})">
  +
</button>

Form + SSE approach

# Before — router.ex
plug Dstar.Plugs.RenameCsrfParam

# After — same plug, same setup
plug Dstar.Plugs.RenameCsrfParam

If you expose the token as a non-prefixed csrf signal for form compatibility, Datastar will include it in every request body. That's expected, and Dstar.Plugs.RenameCsrfParam exists specifically to copy that value into _csrf_token for Plug.CSRFProtection.

Step 7: Update Script Execution & Redirects

The API is nearly identical, just swap socket for conn:

# Before
def handle_event("export", _payload, socket) do
  {:noreply,
   socket
   |> execute_script("window.alert('Done!')")
   |> redirect("/results")}
end

# After
def export(conn, _params) do
  conn
  |> Dstar.start()
  |> Dstar.execute_script("window.alert('Done!')")
  |> Dstar.redirect("/results")
end

Step 8: Clean Up

  1. Delete old view modules (*_star.ex or similar) once they've been converted to controllers/handlers.
  2. Delete any custom DatastarHTML mount template module — Dstar doesn't use one.
  3. Remove import PhoenixDatastar.Router from your router.
  4. Remove import PhoenixDatastar.Actions from any modules still referencing it.
  5. Run mix compile --warnings-as-errors to catch any remaining references to PhoenixDatastar modules.

API Mapping Reference

PhoenixDatastarDstarNotes
put_signal(socket, key, val)Dstar.patch_signals(conn, %{key => val})Template data-signals for initial values
put_signal(socket, map)Dstar.patch_signals(conn, map)
update_signal(socket, key, fun)Read + transform + Dstar.patch_signalsNo shortcut — apply the function yourself
assign(socket, key, val)N/AUse controller assigns or local variables
update(socket, key, fun)N/AUse local variables
patch_elements(socket, sel, fn)Dstar.patch_elements(conn, html, selector: sel)Render HTML before calling
patch_elements(socket, sel, html)Dstar.patch_elements(conn, html, selector: sel)
execute_script(socket, js)Dstar.execute_script(conn, js)
redirect(socket, url)Dstar.redirect(conn, url)
console_log(socket, msg)Dstar.console_log(conn, msg)
event("name")Dstar.post(Module, "name") or @post('/path')All verbs available: get, put, patch, delete
navigate("/path")Standard <a href>No soft nav in Dstar
PhoenixDatastar.Router.datastar/3Standard get/post routes
PhoenixDatastar.Router.datastar_session/3N/ANo sessions in Dstar
{:noreply, socket}Return conn
{:stop, socket}Return conn (connection closes naturally)
handle_info/2 callbackreceive block in stream loop
terminate/1 callbackN/A (process cleanup is automatic)

Common Patterns Comparison

Flash messages

# Before
def handle_event("save", _payload, socket) do
  socket = assign(socket, flash: %{"info" => "Saved!"})
  {:noreply, patch_elements(socket, "#flash", &render_flash/1)}
end

# After
def save(conn, _params) do
  # ... save logic ...
  html = ~s(<div id="flash" class="alert alert-info">Saved!</div>)

  conn
  |> Dstar.start()
  |> Dstar.patch_elements(html, selector: "#flash")
end

Conditional rendering

# Before — use assigns to control render
def handle_event("toggle", _payload, socket) do
  {:noreply, update(socket, :show, &(!&1))}
end

# After — use signals (client-side)
# No server round-trip needed! Use Datastar's data-show:
# <div data-show="$show">...</div>
# <button data-on:click="$show = !$show">Toggle</button>
#
# Or if server logic is needed:
def toggle(conn, _params) do
  signals = Dstar.read_signals(conn)
  conn
  |> Dstar.start()
  |> Dstar.patch_signals(%{show: !signals["show"]})
end

Loading states

# Before — signals on socket
def handle_event("search", %{"query" => q}, socket) do
  socket = put_signal(socket, :loading, true)
  results = MyApp.Search.run(q)
  {:noreply,
   socket
   |> put_signal(:loading, false)
   |> put_signal(:results, results)}
end

# After — signals on conn
def search(conn, _params) do
  signals = Dstar.read_signals(conn)
  results = MyApp.Search.run(signals["query"])

  conn
  |> Dstar.start()
  |> Dstar.patch_signals(%{loading: false, results: results})
end
<%!-- In both cases, the template stays the same --%>
<div data-show="$loading">Searching...</div>

Tip: For instant feedback, use Datastar's data-indicator attribute to show a loading state client-side before the server responds, without any server-side signal patching.


FAQ

Do I need to migrate all at once?

No. You can run both libraries side-by-side during migration since they use different routes and modules. Remove phoenix_datastar from your deps only after all views are converted.

What about session state across requests?

PhoenixDatastar kept state in a GenServer between requests. Dstar is stateless — each request starts fresh. Move persistent state to:

  • The client (Datastar signals — they're sent with every request)
  • The database (for data that must survive across sessions)
  • ETS / Agent (for in-memory shared state)
  • Plug session (for user-specific server state)

What about terminate/1?

If you used terminate/1 for cleanup in live views, that logic should move to wherever you manage the resource lifecycle (e.g., a supervised process, or database cleanup on session expiry).

Can I still use HEEx templates?

Yes. Dstar works with standard Phoenix templates. The only difference is you render them in the controller and pass the HTML string to Dstar.patch_elements/3 instead of returning them from a render/1 callback.

What replaces the mount template / DefaultHTML?

Nothing. Your standard Phoenix layout (root.html.heex, app.html.heex) serves this purpose. Initialize signals with data-signals attributes directly in your templates. data-signals attributes directly in your templates.