patch_element/3 renders a Phoenix function component against the current
connection assigns and sends the resulting HTML as a Datastar
datastar-patch-elements event.
The generated search controller in priv/templates/search_controller.ex.eex
uses this pattern:
mount/2puts initial state on the connection.render/1renders the first page throughLayout.app/1, which emits initial browser signals withinit_signals/1.- Datastar sends an event with the current browser signals.
handle_event/3updates assigns or signals on the connection.patch_element/3re-renders only the component that changed.
Component Example
Write function components the same way you would in a Phoenix view module:
attr :results, :list, default: []
def item_list(assigns) do
~H"""
<ul id="item-list" class="grid gap-2" data-show={query_results("> 0")}>
<.item :for={item <- @results} item={item} />
</ul>
"""
end
attr :item, :string, required: true
def item(assigns) do
~H"""
<li class="border p-4" data-show={starts_with?("'#{@item}'")}>
{@item}
</li>
"""
endWhen the component is rendered from the initial page, attributes come from the parent template:
@impl StarView
def render(assigns) do
~H"""
<Layout.app conn={@conn}>
<.item_list results={@results} />
</Layout.app>
"""
endWhen the component is rendered by patch_element/3, it receives
conn.assigns. Set every value the component needs before patching it:
@impl StarView
def handle_event("search", %{"query" => query}, conn) do
conn
|> signal(:results, get_items(query))
|> patch_element(&item_list/1)
endIn this example, signal(:results, ...) writes conn.assigns.results for the
component and patches $results for the browser.
If the component needs values that are not already in conn.assigns, pass a
small rendering function:
def handle_event("add_item", %{"name" => name}, conn) do
patch_element(conn, fn assigns ->
item(Map.put(assigns, :item, name))
end, to: "item-list", mode: :append)
endPass raw HTML when you already have the rendered content:
patch_element(conn, "<li>Saved</li>", to: "item-list", mode: :append)Full Flow
The active-search template uses signals for state the browser should know about:
@impl StarView
def mount(conn, _params) do
conn
|> signal(:query, "")
|> signal(:results, @items)
endDuring the initial render, those values are available as assigns and as Datastar signals:
@impl StarView
def render(assigns) do
~H"""
<Layout.app conn={@conn}>
<div class="max-w-xl mx-auto p-6">
<.search_form />
<.item_list results={@results} />
<.no_results query={@query} />
</div>
</Layout.app>
"""
endThe input binds directly to the browser signal:
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 user types, Datastar posts the current signal map. StarView starts the SSE response, calls the controller, and the controller returns patched state and patched HTML:
@impl StarView
def handle_event("search", %{"query" => query} = signals, conn) do
conn
|> signal(:results, get_items(query))
|> maybe_patch_list(signals)
end
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)
endThe signals argument is the browser's JSON state at request time, so its keys
are strings. conn.assigns is the server render state after your pipeline runs,
so its keys are atoms. Comparing both lets you skip an element patch when the
server result list did not actually change.
Assigns vs Signals
Use assign/3 when only the server-rendered component needs the value:
def handle_event("show_profile", %{"id" => id}, conn) do
profile = Accounts.get_profile!(id)
conn
|> assign(:profile, profile)
|> 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 server-only data, large structs, values that cannot
be encoded cleanly as JSON, and values the browser should not own.
Use signal/3 when the browser should react to the value or send it back on the
next event:
def handle_event("select_tab", %{"tab" => tab}, conn) do
conn
|> signal(:tab, tab)
|> assign(:items, Items.for_tab(tab))
|> patch_element(&tab_panel/1)
endThe component can read @tab because signal/3 also assigns the value, and the
browser can read $tab in attributes such as data-show, data-text, or
data-bind.
The rule of thumb is:
| State | Use | Why |
|---|---|---|
| Render-only server state | assign/3 | Function components can read it; the browser does not receive it. |
| Browser-visible JSON state | signal/3 | Components can read it and Datastar can react to it. |
| Event input from the browser | signals argument | It is the submitted client state before the handler's updates. |
Targeting
If the rendered element has a stable id, you can often let Datastar target it
from the patched HTML:
patch_element(conn, &item_list/1)Use :to when you want to target a DOM id explicitly. StarView turns the id
into a CSS selector:
patch_element(conn, &item_list/1, to: "item-list", mode: :replace)Use Datastar element options directly when you need more control:
patch_element(conn, &item_list/1, selector: "#item-list", mode: :append)The default element patch mode is :outer. Other supported modes include
:inner, :replace, :prepend, :append, :before, :after, and :remove.
Change Checks
StarView does not maintain LiveView-style change tracking. If a handler can skip an HTML patch, keep that decision explicit:
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)
endSignal patches and element patches are independent. It is valid to update a signal without patching HTML when Datastar can handle the UI change locally, and it is valid to patch HTML from assigns without exposing those assigns as signals.