StarView logo

StarView - Unofficial helpers for DataStar and Phoenix

THIS IS IN ALPHA STAGES EVERYTHING CHANGES DAILY DO NOT USE

Version Downloads Requires Erlang/OTP 27+ License

StarView is an Elixir SDK for Datastar Server-Sent Events. It works with Plug and Phoenix, and uses Erlang's built-in :json module.

The Problem

Building Datastar apps in Elixir means a lot of boilerplate. You manually start SSE connections, track which values to send to the browser, and remember to flush them at the end. It's easy to forget a step.

StarView removes that.

What Makes It Different

signal/3 does two things at once.

It sets a connection assign (so your function components can read it) and patches it to the browser automatically during Datastar requests.

def handle_event("increment", signals, conn) do
  conn
  # Server-only: function components can read @computed_value, browser never sees it
  |> assign(:computed_value, expensive_calculation(signals))
  # Both: function components can read @count, browser gets it too
  |> signal(:count, signals["count"] + 1)
  # Render a function component and patch it into the DOM
  |> patch_element(&history_item/1, to: "history-list", mode: :append)
  # If the function component has an id you can simplify this code to just
  |> patch_element(&history_item/1)
end

No manual start or signal patching.

The dispatch plug starts the SSE response before your handler runs. signal/3 then assigns the value and sends the Datastar signal patch immediately.

Auto-registration.

Any controller that use StarView is automatically dispatchable. No allow-list in your router to maintain.

Installation

Quick (Igniter)

mix igniter.install star_view

This adds the dependency, puts StreamRegistry in your supervision tree, configures HTTPS and https://<otp_app>.test:4001 as the dev URL, patches your router with the dispatch route, and generates a sample controller.

Skip parts you don't want:

mix igniter.install star_view --no-stream-dedup --no-https --no-example

Start Phoenix and open the configured dev URL:

mix dev

mix dev delegates to mix star_view.server.

Manual

def deps do
  [
    {:star_view, "~> 0.3.5"}
  ]
end

Add StarView.StreamRegistry to your supervision tree if you want per-tab stream deduplication.

Add the dispatch route to your router:

scope "/" do
  pipe_through :browser
  post "/ds/:module/:event", StarView.Dispatch, []
end

PhoenixSetup

1. Add a star_view section to your web module:

def star_view do
  quote do
    use Phoenix.Controller, formats: [:html, :json]
    use StarView
    use Phoenix.Component

    use Gettext, backend: MyAppWeb.Gettext

    import Phoenix.Component, except: [assign: 3]
    import Plug.Conn

    unquote(verified_routes())
  end
end

This adds the dependency, puts StreamRegistry in your supervision tree, configures HTTPS in dev, patches your router with the dispatch route, and generates a sample controller.

Skip parts you don't want:

mix igniter.install octa_star --no-stream-dedup --no-https --no-example

Manual

def deps do
  [
    {:octa_star, "~> 0.1.0"}
  ]
end

Add OctaStar.Utility.StreamRegistry to your supervision tree if you want per-tab stream deduplication.

Add the dispatch route to your router:

scope "/" do
  pipe_through :browser
  post "/ds/:module/:event", OctaStar.Phoenix.Dispatch, []
end

Phoenix Setup

1. Add use OctaStar, :controller to your web module:

def controller do
  quote do
    use Phoenix.Controller, formats: [:html]
    use OctaStar, :controller
  end
end

2. Write a controller:

defmodule MyAppWeb.CounterController do
  use MyAppWeb, :star_view

  # Called on page load. Set up initial signals here.
  @impl StarView
  def mount(conn, _params) do
    conn
    |> signal(:count, 0)
  end

  # Render the initial HTML. Use init_signals/1 to send starting values to the browser.
  @impl StarView
  def render(assigns) do
    ~H"""
    <div data-signals={init_signals(@conn)}>
      <button data-on:click={post("increment")}>+</button>
      <span data-text="$count">{@count}</span>
    </div>
    """
  end

  # Called when the user clicks the button. Return the updated conn.
  @impl StarView
  def handle_event("increment", signals, conn) do
    signal(conn, :count, Map.get(signals, "count", 0) + 1)
  end
end

That's it. The dispatcher handles SSE start, calls your handler, and signal/3 sends browser-visible values as your pipeline runs.

assign vs signal

FunctionFunction components see itBrowser sees it
assign(conn, :key, value)YesNo
signal(conn, :key, value)YesYes (initially or immediately during SSE)

Use assign for server-only data you pass to components. Use signal for anything the browser needs to react to.

Patching Function Components

patch_element/3 renders a function component against current assigns and sends the HTML to the browser:

def handle_event("add_item", _signals, conn) do
  conn
  |> assign(:items, ["Ada", "Grace"])
  |> patch_element(&list/1, to: "people", mode: :replace)
end

Pass a function of arity 1 and it receives the assigns map. Pass raw HTML and it sends that directly.

Per-Tab Stream Deduplication

When a user navigates away, the old SSE process can stick around until the next keepalive. That wastes connections. StarView can kill the old stream when a new one starts from the same tab.

Add this to your supervision tree:

children = [
  StarView.StreamRegistry,
  # ...
]

Set a tabId signal in your layout:

<div data-signals={~s({"tabId": "#{Ecto.UUID.generate()}"})}>

Start streams with:

conn = StarView.start_stream(conn, current_user.id)

If no tabId is present, it falls back to a regular stream with no deduplication.

CSRF (Forms)

You usually don't need forms with Datastar. If you do, put the CSRF token in a csrf signal and add this plug before your CSRF protection:

plug StarView.Plug.RenameCsrfParam
plug :protect_from_forgery

Migration from Dstar

DstarStarView
DstarStarView
Dstar.Utility.StreamRegistryStarView.StreamRegistry
$_dstar_module$_star_view_module
Dstar.read_signals/1StarView.read_signals/1
Manual Dstar.start/1Handled by dispatch plug
Manual signal patchingHandled by signal/3

Full API

StarView.start(conn)
StarView.start_stream(conn, user_id)
StarView.check_connection(conn)
StarView.patch_elements(conn, html, selector: "#target", mode: :replace)
StarView.remove_elements(conn, "#target")
StarView.patch_signals(conn, %{count: 1})
StarView.patch_signals_raw(conn, ~s({"count":1}))
StarView.remove_signals(conn, ["user.email"])
StarView.execute_script(conn, "console.log('done')")
StarView.redirect(conn, "/next")
StarView.console_log(conn, "debug")
StarView.read_signals(conn)