StarView.patch_signals/3 sends a Datastar datastar-patch-signals event to
the browser. Use it when you want explicit control over the signal patch:
conn
|> StarView.patch_signals(%{count: 1})
|> StarView.patch_signals(%{query: "", results: []})In Phoenix controllers that use StarView, you usually reach for the
signal/3 helper first. It is a convenience wrapper for the common case where
the same value should be available to server-rendered function components and to
Datastar in the browser.
The signal/3 Helper
signal/3 does two jobs:
- calls
assign/3, so function components can read the value as@key - exposes the value as a Datastar signal, so the browser can read it as
$key
conn
|> signal(:query, "")
|> signal(:results, @items)Underneath, signal/3 checks whether the SSE response has already started.
Before SSE starts, usually during mount/2, it records the signal key and
stores the value in conn.assigns:
@impl StarView
def mount(conn, _params) do
conn
|> signal(:query, "")
|> signal(:results, @items)
endrender/1 then writes those recorded values into the first HTML response with
init_signals/1:
@impl StarView
def render(assigns) do
~H"""
<div class="max-w-xl mx-auto p-6" data-signals={init_signals(@conn)}>
<.search_form />
<.item_list results={@results} />
<.no_results query={@query} />
</div>
"""
endAfter SSE starts, usually inside handle_event/3, signal/3 still updates
conn.assigns, then calls StarView.patch_signals/3 for you:
@impl StarView
def handle_event("search", %{"query" => query} = signals, conn) do
conn
|> signal(:results, get_items(query))
|> maybe_patch_list(signals)
endThat is equivalent to assigning the value and then sending a signal patch:
conn
|> assign(:results, results)
|> StarView.patch_signals(%{results: results})The helper keeps those two operations together so the component render state and browser signal state do not drift.
Full Flow
The generated search controller in priv/templates/search_controller.ex.eex
uses signals for the query and result list.
The search input binds to $query in the browser:
def search_form(assigns) do
~H"""
<div class="mb-4 flex gap-2">
<input
type="text"
class="input grow"
placeholder="Search frameworks..."
data-bind:query
data-on:input__debounce.200ms={post("search")}
/>
<button class="btn" data-on:click={post("reset")}>
Reset
</button>
</div>
"""
endWhen the input posts to "search", Datastar sends the current browser signal
map. StarView reads that JSON, starts the SSE response, and calls the event
handler:
@impl StarView
def handle_event("search", %{"query" => query} = signals, conn) do
conn
|> signal(:results, get_items(query))
|> maybe_patch_list(signals)
endThe signals argument is the browser's submitted state, so its keys are
strings. The updated values are in conn.assigns, so their keys are atoms:
defp maybe_patch_list(%{assigns: %{results: results}} = conn, %{"results" => results}) do
conn
end
defp maybe_patch_list(conn, _signals) do
patch_element(conn, &item_list/1)
endThis comparison lets the handler skip an HTML patch when the server result list
matches the browser's current $results signal.
Manual Patching
Use StarView.patch_signals/3 directly when you want explicit control:
def handle_event("search", %{"query" => query}, conn) do
results = get_items(query)
conn
|> StarView.patch_signals(%{results: results})
|> patch_element(fn assigns ->
item_list(Map.put(assigns, :results, results))
end)
endManual patching is useful when:
- you are writing lower-level Plug code instead of a StarView controller
- you want to send a signal patch without changing
conn.assigns - you want to patch several signal keys in one explicit call
- you want to use
patch_signals_raw/3with pre-encoded JSON
The important difference is that StarView.patch_signals/3 does not call
assign/3. If a later component patch needs the same value, assign it yourself
or use signal/3.
conn
|> assign(:results, results)
|> StarView.patch_signals(%{results: results})
|> patch_element(&item_list/1)Assigns vs Signals
Use assign/3 when only the server-rendered component needs the value:
def handle_event("show_profile", %{"id" => id}, conn) do
conn
|> assign(:profile, Accounts.get_profile!(id))
|> patch_element(&profile_card/1, to: "profile")
endThe browser receives only the HTML patch. It does not receive profile as JSON.
This is the right choice for database structs, authorization-sensitive data,
large payloads, and values that only affect the next rendered component.
Use signal/3 when the browser should react to the value, bind to it, or send
it back on the next event:
def handle_event("reset", signals, conn) do
conn
|> signal(:query, "")
|> signal(:results, @items)
|> maybe_patch_list(signals)
endThe practical rule is:
| Function | Function components see it | Browser sees it | Use it for |
|---|---|---|---|
assign(conn, :key, value) | Yes | No | Server-only render data |
signal(conn, :key, value) | Yes | Yes | Shared server and browser state |
StarView.patch_signals(conn, map) | No | Yes | Explicit browser-only signal patches |
Reading Signals
Most Phoenix controller events should use the signals argument passed to
handle_event/3. Lower-level Plug code can read the same payload manually:
signals = StarView.read_signals(conn)GET and DELETE requests read the datastar query parameter. Other methods
read JSON from the request body unless Plug parsers have already populated
conn.body_params.
Raw Patches And Removal
Use patch_signals_raw/3 when you already have encoded JSON:
StarView.patch_signals_raw(conn, ~s({"count":3}))Use remove_signals/2 to remove one or more signal paths:
StarView.remove_signals(conn, ["user.email", "user.name"])Removal is encoded as null values using RFC 7386 JSON Merge Patch semantics.
Options
signal/4 accepts the same signal patch options as StarView.patch_signals/3:
signal(conn, :feature_flags, flags, only_if_missing: true)During an SSE event, the option is applied to the immediate
datastar-patch-signals event. With manual control, pass the option directly:
StarView.patch_signals(conn, %{feature_flags: flags}, only_if_missing: true)