StarView - Unofficial helpers for Datastar and Phoenix
THIS IS IN ALPHA STAGES EVERYTHING CHANGES DAILY DO NOT USE
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 sets up the StarView Phoenix development flow out of the box:
- Adds the dependency.
- Adds
StarView.StreamRegistryto your supervision tree. - Adds a dedicated
star_viewsection to your web module aftercontroller. - Generates
YourAppWeb.Components.StarView.Layoutfor the StarView root layout and page wrapper. - Configures HTTPS and
https://<hyphenated-otp-app>.test:4001as the dev URL. - Provides
mix star_view.trustto add the local host entry and generate a browser-trusted HTTPS certificate withmkcert. - Patches your router with
/searchand/ds/:module/:eventroutes. - Generates a sample search controller.
- Provides
mix dev, which delegates tomix star_view.server.
Install mkcert first:
brew install mkcert nss
Then run the trust step directly after installation:
mix star_view.trust
It adds the .test host to /etc/hosts, runs mkcert -install, and writes
priv/cert/selfsigned.pem plus priv/cert/selfsigned_key.pem. It may prompt
for sudo privileges. Run it before mix dev so Phoenix can find the configured
certificate files.
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
Manual
def deps do
[
{:star_view, "~> 0.3.16"}
]
end
Add StarView.StreamRegistry to your supervision tree if you want
per-tab stream deduplication.
Add a star_view section to your web module. Place it after the existing
controller section so controller-style helpers stay grouped together:
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
alias MyAppWeb.Components.StarView.Layout
plug :put_root_layout, html: {Layout, :root}
unquote(verified_routes())
end
end
Create MyAppWeb.Components.StarView.Layout for the root layout and Layout.app/1
wrapper. The Igniter installer generates this from priv/templates/layout.eex.
Add the dispatch route to your router:
scope "/", MyAppWeb do
pipe_through :browser
post "/ds/:module/:event", StarView.Dispatch, [], alias: false
end
alias: false keeps Phoenix from resolving the dispatch plug as
MyAppWeb.StarView.Dispatch inside the scoped router block.
Phoenix Setup
Write a controller with the :star_view web-module section:
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. Layout.app sends starting signal values to the browser.
@impl StarView
def render(assigns) do
~H"""
<Layout.app conn={@conn}>
<button data-on:click={post("increment")}>+</button>
<span data-text="$count">{@count}</span>
</Layout.app>
"""
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
| Function | Function components see it | Browser sees it |
|---|---|---|
assign(conn, :key, value) | Yes | No |
signal(conn, :key, value) | Yes | Yes (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
| Dstar | StarView |
|---|---|
Dstar | StarView |
Dstar.Utility.StreamRegistry | StarView.StreamRegistry |
$_dstar_module | $_star_view_module |
Dstar.read_signals/1 | StarView.read_signals/1 |
Manual Dstar.start/1 | Handled by dispatch plug |
| Manual signal patching | Handled 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)